detached ab9dbee

fix(wayland): correct workspace IDs and event batches

commit
ab9dbeede788e1ba13ba48a810fc9853c1d8a329
author
brice · · 2026-09-27 13:51 UTC
parent
e16ebd1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +164 -34
M internal/adapters/wayland/outputs.go +3 -1
@@ -140,7 +140,9 @@ func (s *Server) addOutput(p ports.OutputPlacement) {
140140 r.OnDestroy = func() { o.resources = removeItem(o.resources, r) }
141141 o.sendAll(r)
142142 for _, m := range s.workspaceManagers {
143- m.outputBound(o.name())
143+ if m.outputBound(o.name()) {
144+ m.res.SendDone()
145+ }
144146 }
145147 })
146148 if err != nil {
@@ -140,7 +140,9 @@ func (s *Server) addOutput(p ports.OutputPlacement) {
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 {142 for _, m := range s.workspaceManagers {
143- m.outputBound(o.name())143+ if m.outputBound(o.name()) {
144+ m.res.SendDone()
145+ }
144 }146 }
145 })147 })
146 if err != nil {148 if err != nil {
M internal/adapters/wayland/workspaces.go +7 -8
@@ -4,7 +4,6 @@ import (
44 "context"
55 "encoding/binary"
66 "reflect"
7- "strconv"
87
98 "github.com/bnema/neferwl/internal/ports"
109 ext "github.com/bnema/purego-libwayland/protocol/extworkspace"
@@ -86,14 +85,14 @@ func (m *workspaceManager) outputResource(name string) *wayland.Output {
8685 }
8786 return nil
8887 }
89-func (m *workspaceManager) outputBound(name string) {
88+func (m *workspaceManager) outputBound(name string) bool {
9089 g := m.groups[name]
9190 if g == nil || !g.res.Alive() {
92- return
91+ return false
9392 }
9493 r := m.outputResource(name)
9594 if r == g.output {
96- return
95+ return false
9796 }
9897 if g.output != nil && g.output.Alive() {
9998 g.res.SendOutputLeave(g.output)
@@ -102,7 +101,7 @@ func (m *workspaceManager) outputBound(name string) {
102101 if r != nil {
103102 g.res.SendOutputEnter(r)
104103 }
105- m.res.SendDone()
104+ return true
106105 }
107106 func (s *Server) forwardWorkspaces(ctx context.Context) {
108107 for {
@@ -182,6 +181,9 @@ func (m *workspaceManager) update(snapshot ports.Workspaces) {
182181 h = &workspaceHandle{res: r}
183182 m.handles[w.ID] = h
184183 m.res.SendWorkspace(r)
184+ if w.Configured != "" {
185+ r.SendId(w.Configured)
186+ }
185187 r.SendCapabilities(uint32(ext.ExtWorkspaceHandleV1WorkspaceCapabilitiesActivate))
186188 }
187189 if h.group != out.Name {
@@ -210,9 +212,6 @@ func (m *workspaceManager) update(snapshot ports.Workspaces) {
210212 }
211213 h.res.SendState(state)
212214 }
213- if h.info.ID == 0 && w.Name != "" {
214- h.res.SendId(strconv.FormatUint(w.ID, 10))
215- }
216215 h.info = w
217216 }
218217 }
@@ -4,7 +4,6 @@ import (
4 "context"4 "context"
5 "encoding/binary"5 "encoding/binary"
6 "reflect"6 "reflect"
7- "strconv"
8 7
9 "github.com/bnema/neferwl/internal/ports"8 "github.com/bnema/neferwl/internal/ports"
10 ext "github.com/bnema/purego-libwayland/protocol/extworkspace"9 ext "github.com/bnema/purego-libwayland/protocol/extworkspace"
@@ -86,14 +85,14 @@ func (m *workspaceManager) outputResource(name string) *wayland.Output {
86 }85 }
87 return nil86 return nil
88 }87 }
89-func (m *workspaceManager) outputBound(name string) {88+func (m *workspaceManager) outputBound(name string) bool {
90 g := m.groups[name]89 g := m.groups[name]
91 if g == nil || !g.res.Alive() {90 if g == nil || !g.res.Alive() {
92- return91+ return false
93 }92 }
94 r := m.outputResource(name)93 r := m.outputResource(name)
95 if r == g.output {94 if r == g.output {
96- return95+ return false
97 }96 }
98 if g.output != nil && g.output.Alive() {97 if g.output != nil && g.output.Alive() {
99 g.res.SendOutputLeave(g.output)98 g.res.SendOutputLeave(g.output)
@@ -102,7 +101,7 @@ func (m *workspaceManager) outputBound(name string) {
102 if r != nil {101 if r != nil {
103 g.res.SendOutputEnter(r)102 g.res.SendOutputEnter(r)
104 }103 }
105- m.res.SendDone()104+ return true
106 }105 }
107 func (s *Server) forwardWorkspaces(ctx context.Context) {106 func (s *Server) forwardWorkspaces(ctx context.Context) {
108 for {107 for {
@@ -182,6 +181,9 @@ func (m *workspaceManager) update(snapshot ports.Workspaces) {
182 h = &workspaceHandle{res: r}181 h = &workspaceHandle{res: r}
183 m.handles[w.ID] = h182 m.handles[w.ID] = h
184 m.res.SendWorkspace(r)183 m.res.SendWorkspace(r)
184+ if w.Configured != "" {
185+ r.SendId(w.Configured)
186+ }
185 r.SendCapabilities(uint32(ext.ExtWorkspaceHandleV1WorkspaceCapabilitiesActivate))187 r.SendCapabilities(uint32(ext.ExtWorkspaceHandleV1WorkspaceCapabilitiesActivate))
186 }188 }
187 if h.group != out.Name {189 if h.group != out.Name {
@@ -210,9 +212,6 @@ func (m *workspaceManager) update(snapshot ports.Workspaces) {
210 }212 }
211 h.res.SendState(state)213 h.res.SendState(state)
212 }214 }
213- if h.info.ID == 0 && w.Name != "" {
214- h.res.SendId(strconv.FormatUint(w.ID, 10))
215- }
216 h.info = w215 h.info = w
217 }216 }
218 }217 }
M internal/adapters/wayland/workspaces_test.go +118 -8
@@ -13,15 +13,16 @@ import (
1313
1414 type workspaceEvents struct {
1515 wlturbo.BaseProxy
16- client *wlturbo.Display
17- events chan [2]uint32
16+ client *wlturbo.Display
17+ events chan [2]uint32
18+ idEvents chan string
1819 }
1920
2021 func (p *workspaceEvents) Dispatch(e *wlturbo.Event) {
2122 v := [2]uint32{uint32(e.Opcode), 0}
2223 if e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup) || e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspace) {
2324 v[1] = e.Uint32()
24- child := &workspaceChildEvents{events: p.events, group: e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup)}
25+ child := &workspaceChildEvents{events: p.events, idEvents: p.idEvents, group: e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup)}
2526 child.SetID(v[1])
2627 p.client.Context().Register(child)
2728 }
@@ -30,8 +31,9 @@ func (p *workspaceEvents) Dispatch(e *wlturbo.Event) {
3031
3132 type workspaceChildEvents struct {
3233 wlturbo.BaseProxy
33- events chan [2]uint32
34- group bool
34+ events chan [2]uint32
35+ group bool
36+ idEvents chan string
3537 }
3638
3739 func (p *workspaceChildEvents) Dispatch(e *wlturbo.Event) {
@@ -48,6 +50,8 @@ func (p *workspaceChildEvents) Dispatch(e *wlturbo.Event) {
4850 v[1] = e.Uint32()
4951 case !p.group && (e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventState) || e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventCapabilities)):
5052 v[1] = e.Uint32()
53+ case !p.group && e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventId):
54+ p.idEvents <- e.String()
5155 }
5256 p.events <- v
5357 }
@@ -74,7 +78,7 @@ func TestWorkspaceProtocol(t *testing.T) {
7478 t.Fatal("display stopped")
7579 }
7680 managerID := bindProtocol(t, c, "ext_workspace_manager_v1")
77- p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128)}
81+ p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16)}
7882 p.SetID(managerID)
7983 c.Context().Register(p)
8084 var handle uint32
@@ -92,9 +96,15 @@ func TestWorkspaceProtocol(t *testing.T) {
9296 if handle == 0 {
9397 t.Fatal("workspace not announced")
9498 }
99+ select {
100+ case id := <-p.idEvents:
101+ t.Fatalf("dynamic workspace sent stable id %q", id)
102+ default:
103+ }
95104 out := bindProtocol(t, c, "wl_output")
96105 registerProtocol(t, c, out)
97106 entered := false
107+ doneCount := 0
98108 for i := 0; i < 8 && !entered; i++ {
99109 if err := c.Roundtrip(); err != nil {
100110 t.Fatal(err)
@@ -104,10 +114,24 @@ func TestWorkspaceProtocol(t *testing.T) {
104114 if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, out} {
105115 entered = true
106116 }
117+ if v[0] == ext.ExtWorkspaceManagerV1EventDone {
118+ doneCount++
119+ }
120+ }
121+ }
122+ if err := c.Roundtrip(); err != nil {
123+ t.Fatal(err)
124+ }
125+ for len(p.events) > 0 {
126+ if (<-p.events)[0] == ext.ExtWorkspaceManagerV1EventDone {
127+ doneCount++
107128 }
108129 }
109- if !entered {
110- t.Fatal("late wl_output did not enter group")
130+ if !entered || doneCount != 1 {
131+ t.Fatalf("late output enter=%v, done=%d", entered, doneCount)
132+ }
133+ if len(p.idEvents) != 0 {
134+ t.Fatal("dynamic workspace sent id")
111135 }
112136 requestProtocol(t, c, handle, ext.ExtWorkspaceHandleV1RequestActivate)
113137 if err := c.Roundtrip(); err != nil {
@@ -163,3 +187,89 @@ func TestWorkspaceProtocol(t *testing.T) {
163187 t.Fatal("group not removed")
164188 }
165189 }
190+
191+func TestWorkspaceConfiguredIDAndBatchDone(t *testing.T) {
192+ dir := t.TempDir()
193+ snapshots := make(chan ports.Workspaces, 1)
194+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Workspaces: snapshots}, logging.For(context.Background(), "wayland"))
195+ if err != nil {
196+ t.Fatal(err)
197+ }
198+ ctx, cancel := context.WithCancel(context.Background())
199+ done := make(chan error, 1)
200+ go func() { done <- s.Run(ctx) }()
201+ t.Cleanup(func() {
202+ cancel()
203+ if err := <-done; err != nil {
204+ t.Error(err)
205+ }
206+ })
207+ c := protocolClient(t, s, dir)
208+ // Bind manager first; the initial two-output batch has exactly one done.
209+ managerID := bindProtocol(t, c, "ext_workspace_manager_v1")
210+ p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16)}
211+ p.SetID(managerID)
212+ c.Context().Register(p)
213+ if err := c.Roundtrip(); err != nil {
214+ t.Fatal(err)
215+ }
216+ for len(p.events) > 0 {
217+ <-p.events
218+ }
219+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{
220+ {Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Active: true}, {ID: 43, Name: "dev", Configured: "dev", Index: 1, Hidden: true}}},
221+ {Name: "HEADLESS-2", Workspaces: []ports.WorkspaceInfo{{ID: 44, Name: "1", Active: true}}},
222+ }}
223+ // Synchronize with the display owner's processing of the channel snapshot.
224+ for i := 0; i < 8; i++ {
225+ if !s.display.Do(func() {}) {
226+ t.Fatal("display stopped")
227+ }
228+ if err := c.Roundtrip(); err != nil {
229+ t.Fatal(err)
230+ }
231+ if len(p.events) > 0 {
232+ break
233+ }
234+ }
235+ doneCount := 0
236+ for len(p.events) > 0 {
237+ if (<-p.events)[0] == ext.ExtWorkspaceManagerV1EventDone {
238+ doneCount++
239+ }
240+ }
241+ if doneCount != 1 {
242+ t.Fatalf("snapshot done=%d, want 1", doneCount)
243+ }
244+ select {
245+ case id := <-p.idEvents:
246+ if id != "dev" {
247+ t.Fatalf("id=%q", id)
248+ }
249+ default:
250+ t.Fatal("configured workspace has no id")
251+ }
252+ if len(p.idEvents) != 0 {
253+ t.Fatal("dynamic workspace received id")
254+ }
255+ out := bindProtocol(t, c, "wl_output")
256+ registerProtocol(t, c, out)
257+ entered, outputDone := 0, 0
258+ for i := 0; i < 8 && outputDone == 0; i++ {
259+ if err := c.Roundtrip(); err != nil {
260+ t.Fatal(err)
261+ }
262+ for len(p.events) > 0 {
263+ v := <-p.events
264+ if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, out} {
265+ entered++
266+ }
267+ if v[0] == ext.ExtWorkspaceManagerV1EventDone {
268+ outputDone++
269+ }
270+ }
271+ }
272+ if entered != 1 || outputDone != 1 {
273+ t.Fatalf("late output enter=%d done=%d", entered, outputDone)
274+ }
275+}
@@ -13,15 +13,16 @@ import (
13 13
14 type workspaceEvents struct {14 type workspaceEvents struct {
15 wlturbo.BaseProxy15 wlturbo.BaseProxy
16- client *wlturbo.Display16+ client *wlturbo.Display
17- events chan [2]uint3217+ events chan [2]uint32
18+ idEvents chan string
18 }19 }
19 20
20 func (p *workspaceEvents) Dispatch(e *wlturbo.Event) {21 func (p *workspaceEvents) Dispatch(e *wlturbo.Event) {
21 v := [2]uint32{uint32(e.Opcode), 0}22 v := [2]uint32{uint32(e.Opcode), 0}
22 if e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup) || e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspace) {23 if e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup) || e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspace) {
23 v[1] = e.Uint32()24 v[1] = e.Uint32()
24- child := &workspaceChildEvents{events: p.events, group: e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup)}25+ child := &workspaceChildEvents{events: p.events, idEvents: p.idEvents, group: e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup)}
25 child.SetID(v[1])26 child.SetID(v[1])
26 p.client.Context().Register(child)27 p.client.Context().Register(child)
27 }28 }
@@ -30,8 +31,9 @@ func (p *workspaceEvents) Dispatch(e *wlturbo.Event) {
30 31
31 type workspaceChildEvents struct {32 type workspaceChildEvents struct {
32 wlturbo.BaseProxy33 wlturbo.BaseProxy
33- events chan [2]uint3234+ events chan [2]uint32
34- group bool35+ group bool
36+ idEvents chan string
35 }37 }
36 38
37 func (p *workspaceChildEvents) Dispatch(e *wlturbo.Event) {39 func (p *workspaceChildEvents) Dispatch(e *wlturbo.Event) {
@@ -48,6 +50,8 @@ func (p *workspaceChildEvents) Dispatch(e *wlturbo.Event) {
48 v[1] = e.Uint32()50 v[1] = e.Uint32()
49 case !p.group && (e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventState) || e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventCapabilities)):51 case !p.group && (e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventState) || e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventCapabilities)):
50 v[1] = e.Uint32()52 v[1] = e.Uint32()
53+ case !p.group && e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventId):
54+ p.idEvents <- e.String()
51 }55 }
52 p.events <- v56 p.events <- v
53 }57 }
@@ -74,7 +78,7 @@ func TestWorkspaceProtocol(t *testing.T) {
74 t.Fatal("display stopped")78 t.Fatal("display stopped")
75 }79 }
76 managerID := bindProtocol(t, c, "ext_workspace_manager_v1")80 managerID := bindProtocol(t, c, "ext_workspace_manager_v1")
77- p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128)}81+ p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16)}
78 p.SetID(managerID)82 p.SetID(managerID)
79 c.Context().Register(p)83 c.Context().Register(p)
80 var handle uint3284 var handle uint32
@@ -92,9 +96,15 @@ func TestWorkspaceProtocol(t *testing.T) {
92 if handle == 0 {96 if handle == 0 {
93 t.Fatal("workspace not announced")97 t.Fatal("workspace not announced")
94 }98 }
99+ select {
100+ case id := <-p.idEvents:
101+ t.Fatalf("dynamic workspace sent stable id %q", id)
102+ default:
103+ }
95 out := bindProtocol(t, c, "wl_output")104 out := bindProtocol(t, c, "wl_output")
96 registerProtocol(t, c, out)105 registerProtocol(t, c, out)
97 entered := false106 entered := false
107+ doneCount := 0
98 for i := 0; i < 8 && !entered; i++ {108 for i := 0; i < 8 && !entered; i++ {
99 if err := c.Roundtrip(); err != nil {109 if err := c.Roundtrip(); err != nil {
100 t.Fatal(err)110 t.Fatal(err)
@@ -104,10 +114,24 @@ func TestWorkspaceProtocol(t *testing.T) {
104 if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, out} {114 if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, out} {
105 entered = true115 entered = true
106 }116 }
117+ if v[0] == ext.ExtWorkspaceManagerV1EventDone {
118+ doneCount++
119+ }
120+ }
121+ }
122+ if err := c.Roundtrip(); err != nil {
123+ t.Fatal(err)
124+ }
125+ for len(p.events) > 0 {
126+ if (<-p.events)[0] == ext.ExtWorkspaceManagerV1EventDone {
127+ doneCount++
107 }128 }
108 }129 }
109- if !entered {130+ if !entered || doneCount != 1 {
110- t.Fatal("late wl_output did not enter group")131+ t.Fatalf("late output enter=%v, done=%d", entered, doneCount)
132+ }
133+ if len(p.idEvents) != 0 {
134+ t.Fatal("dynamic workspace sent id")
111 }135 }
112 requestProtocol(t, c, handle, ext.ExtWorkspaceHandleV1RequestActivate)136 requestProtocol(t, c, handle, ext.ExtWorkspaceHandleV1RequestActivate)
113 if err := c.Roundtrip(); err != nil {137 if err := c.Roundtrip(); err != nil {
@@ -163,3 +187,89 @@ func TestWorkspaceProtocol(t *testing.T) {
163 t.Fatal("group not removed")187 t.Fatal("group not removed")
164 }188 }
165 }189 }
190+
191+func TestWorkspaceConfiguredIDAndBatchDone(t *testing.T) {
192+ dir := t.TempDir()
193+ snapshots := make(chan ports.Workspaces, 1)
194+ s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Workspaces: snapshots}, logging.For(context.Background(), "wayland"))
195+ if err != nil {
196+ t.Fatal(err)
197+ }
198+ ctx, cancel := context.WithCancel(context.Background())
199+ done := make(chan error, 1)
200+ go func() { done <- s.Run(ctx) }()
201+ t.Cleanup(func() {
202+ cancel()
203+ if err := <-done; err != nil {
204+ t.Error(err)
205+ }
206+ })
207+ c := protocolClient(t, s, dir)
208+ // Bind manager first; the initial two-output batch has exactly one done.
209+ managerID := bindProtocol(t, c, "ext_workspace_manager_v1")
210+ p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16)}
211+ p.SetID(managerID)
212+ c.Context().Register(p)
213+ if err := c.Roundtrip(); err != nil {
214+ t.Fatal(err)
215+ }
216+ for len(p.events) > 0 {
217+ <-p.events
218+ }
219+ snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{
220+ {Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Active: true}, {ID: 43, Name: "dev", Configured: "dev", Index: 1, Hidden: true}}},
221+ {Name: "HEADLESS-2", Workspaces: []ports.WorkspaceInfo{{ID: 44, Name: "1", Active: true}}},
222+ }}
223+ // Synchronize with the display owner's processing of the channel snapshot.
224+ for i := 0; i < 8; i++ {
225+ if !s.display.Do(func() {}) {
226+ t.Fatal("display stopped")
227+ }
228+ if err := c.Roundtrip(); err != nil {
229+ t.Fatal(err)
230+ }
231+ if len(p.events) > 0 {
232+ break
233+ }
234+ }
235+ doneCount := 0
236+ for len(p.events) > 0 {
237+ if (<-p.events)[0] == ext.ExtWorkspaceManagerV1EventDone {
238+ doneCount++
239+ }
240+ }
241+ if doneCount != 1 {
242+ t.Fatalf("snapshot done=%d, want 1", doneCount)
243+ }
244+ select {
245+ case id := <-p.idEvents:
246+ if id != "dev" {
247+ t.Fatalf("id=%q", id)
248+ }
249+ default:
250+ t.Fatal("configured workspace has no id")
251+ }
252+ if len(p.idEvents) != 0 {
253+ t.Fatal("dynamic workspace received id")
254+ }
255+ out := bindProtocol(t, c, "wl_output")
256+ registerProtocol(t, c, out)
257+ entered, outputDone := 0, 0
258+ for i := 0; i < 8 && outputDone == 0; i++ {
259+ if err := c.Roundtrip(); err != nil {
260+ t.Fatal(err)
261+ }
262+ for len(p.events) > 0 {
263+ v := <-p.events
264+ if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, out} {
265+ entered++
266+ }
267+ if v[0] == ext.ExtWorkspaceManagerV1EventDone {
268+ outputDone++
269+ }
270+ }
271+ }
272+ if entered != 1 || outputDone != 1 {
273+ t.Fatalf("late output enter=%d done=%d", entered, outputDone)
274+ }
275+}
M internal/core/core.go +5 -2
@@ -47,6 +47,7 @@ type binding struct {
4747 key string
4848 }
4949 type Core struct {
50+ nextWorkspaceID uint64
5051 ch Channels
5152 cfg ports.Config
5253 screens []*screen
@@ -289,7 +290,8 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
289290 return nil, fmt.Errorf("scenes, layouts, constraints and state must have capacity 1")
290291 }
291292 // 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{}, 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{}}
293+ c := &Core{slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, 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{}}
294+ c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
293295 if err := c.apply(cfg); err != nil {
294296 return nil, err
295297 }
@@ -719,12 +721,13 @@ func (c *Core) Run(ctx context.Context) error {
719721 s.mon.SetFullscreen(v.ID, v.Fullscreen)
720722 }
721723 case ports.WorkspaceActivate:
722- for _, sc := range c.screens {
724+ for i, sc := range c.screens {
723725 if sc.name() == "" {
724726 continue
725727 }
726728 for _, w := range sc.mon.all() {
727729 if w.ID == v.ID {
730+ c.focusScreen = i
728731 sc.mon.show(w)
729732 break
730733 }
@@ -47,6 +47,7 @@ type binding struct {
47 key string47 key string
48 }48 }
49 type Core struct {49 type Core struct {
50+ nextWorkspaceID uint64
50 ch Channels51 ch Channels
51 cfg ports.Config52 cfg ports.Config
52 screens []*screen53 screens []*screen
@@ -289,7 +290,8 @@ func New(cfg ports.Config, ch Channels) (*Core, error) {
289 return nil, fmt.Errorf("scenes, layouts, constraints and state must have capacity 1")290 return nil, fmt.Errorf("scenes, layouts, constraints and state must have capacity 1")
290 }291 }
291 // A placeholder screen holds windows until the first output arrives.292 // 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{}, 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{}}293+ c := &Core{slots: map[slotKey]*slotState{}, terms: map[string]*termSpawn{}, clients: map[WindowID]ports.WindowMapped{}, 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{}}
294+ c.screens = []*screen{{mon: newMonitorWithIDs("", "", &c.nextWorkspaceID), scale: 1, cfgScale: 1}}
293 if err := c.apply(cfg); err != nil {295 if err := c.apply(cfg); err != nil {
294 return nil, err296 return nil, err
295 }297 }
@@ -719,12 +721,13 @@ func (c *Core) Run(ctx context.Context) error {
719 s.mon.SetFullscreen(v.ID, v.Fullscreen)721 s.mon.SetFullscreen(v.ID, v.Fullscreen)
720 }722 }
721 case ports.WorkspaceActivate:723 case ports.WorkspaceActivate:
722- for _, sc := range c.screens {724+ for i, sc := range c.screens {
723 if sc.name() == "" {725 if sc.name() == "" {
724 continue726 continue
725 }727 }
726 for _, w := range sc.mon.all() {728 for _, w := range sc.mon.all() {
727 if w.ID == v.ID {729 if w.ID == v.ID {
730+ c.focusScreen = i
728 sc.mon.show(w)731 sc.mon.show(w)
729 break732 break
730 }733 }
M internal/core/monitor.go +9 -8
@@ -1,11 +1,6 @@
11 package core
22
3-import (
4- "slices"
5- "sync/atomic"
6-)
7-
8-var nextWorkspaceID atomic.Uint64
3+import "slices"
94
105 // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers
116 // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits
@@ -26,6 +21,7 @@ type Monitor struct {
2621 shown *Workspace // hidden workspace on screen, nil for Workspaces[Active]
2722 back *Workspace // where a named workspace toggle returns to
2823 template Workspace
24+ nextID *uint64 // shared by the monitors of one Core; owner goroutine only
2925 named []NamedWorkspace
3026 // followMove shows the target workspace after a move to it.
3127 followMove bool
@@ -45,7 +41,11 @@ type NamedWorkspace struct {
4541 func NewMonitor() *Monitor { return newMonitor("", "") }
4642
4743 func newMonitor(name, key string) *Monitor {
48- m := &Monitor{Name: name, Key: key}
44+ return newMonitorWithIDs(name, key, new(uint64))
45+}
46+
47+func newMonitorWithIDs(name, key string, nextID *uint64) *Monitor {
48+ m := &Monitor{Name: name, Key: key, nextID: nextID}
4949 m.normalize()
5050 return m
5151 }
@@ -125,7 +125,8 @@ func (m *Monitor) newWorkspace() *Workspace {
125125 w := m.template
126126 w.presets = append([]Width(nil), m.template.presets...)
127127 w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{}
128- w.ID = nextWorkspaceID.Add(1)
128+ (*m.nextID)++
129+ w.ID = *m.nextID
129130 return &w
130131 }
131132
@@ -1,11 +1,6 @@
1 package core1 package core
2 2
3-import (3+import "slices"
4- "slices"
5- "sync/atomic"
6-)
7-
8-var nextWorkspaceID atomic.Uint64
9 4
10 // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers5 // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers
11 // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits6 // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits
@@ -26,6 +21,7 @@ type Monitor struct {
26 shown *Workspace // hidden workspace on screen, nil for Workspaces[Active]21 shown *Workspace // hidden workspace on screen, nil for Workspaces[Active]
27 back *Workspace // where a named workspace toggle returns to22 back *Workspace // where a named workspace toggle returns to
28 template Workspace23 template Workspace
24+ nextID *uint64 // shared by the monitors of one Core; owner goroutine only
29 named []NamedWorkspace25 named []NamedWorkspace
30 // followMove shows the target workspace after a move to it.26 // followMove shows the target workspace after a move to it.
31 followMove bool27 followMove bool
@@ -45,7 +41,11 @@ type NamedWorkspace struct {
45 func NewMonitor() *Monitor { return newMonitor("", "") }41 func NewMonitor() *Monitor { return newMonitor("", "") }
46 42
47 func newMonitor(name, key string) *Monitor {43 func newMonitor(name, key string) *Monitor {
48- m := &Monitor{Name: name, Key: key}44+ return newMonitorWithIDs(name, key, new(uint64))
45+}
46+
47+func newMonitorWithIDs(name, key string, nextID *uint64) *Monitor {
48+ m := &Monitor{Name: name, Key: key, nextID: nextID}
49 m.normalize()49 m.normalize()
50 return m50 return m
51 }51 }
@@ -125,7 +125,8 @@ func (m *Monitor) newWorkspace() *Workspace {
125 w := m.template125 w := m.template
126 w.presets = append([]Width(nil), m.template.presets...)126 w.presets = append([]Width(nil), m.template.presets...)
127 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)128+ (*m.nextID)++
129+ w.ID = *m.nextID
129 return &w130 return &w
130 }131 }
131 132
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()
M internal/core/workspaces.go +5 -4
@@ -1,14 +1,15 @@
11 package core
22
33 import (
4- "github.com/bnema/neferwl/internal/ports"
54 "reflect"
65 "strconv"
6+
7+ "github.com/bnema/neferwl/internal/ports"
78 )
89
910 // 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.
11+// workspaces. Config-hidden named workspaces and empty numbered spares are
12+// hidden unless shown; unlike State, hidden named workspaces remain addressable.
1213 func (c *Core) publishWorkspaces() {
1314 if c.ch.Workspaces == nil {
1415 return
@@ -25,7 +26,7 @@ func (c *Core) publishWorkspaces() {
2526 if name == "" {
2627 name = strconv.Itoa(i + 1)
2728 }
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+ 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})
2930 }
3031 snapshot.Outputs = append(snapshot.Outputs, out)
3132 }
@@ -1,14 +1,15 @@
1 package core1 package core
2 2
3 import (3 import (
4- "github.com/bnema/neferwl/internal/ports"
5 "reflect"4 "reflect"
6 "strconv"5 "strconv"
6+
7+ "github.com/bnema/neferwl/internal/ports"
7 )8 )
8 9
9 // publishWorkspaces describes the numbered vertical stack and configured hidden10 // publishWorkspaces describes the numbered vertical stack and configured hidden
10-// workspaces. Empty numbered spares are hidden unless currently shown; unlike11+// workspaces. Config-hidden named workspaces and empty numbered spares are
11-// the script State snapshot, hidden named workspaces remain addressable.12+// hidden unless shown; unlike State, hidden named workspaces remain addressable.
12 func (c *Core) publishWorkspaces() {13 func (c *Core) publishWorkspaces() {
13 if c.ch.Workspaces == nil {14 if c.ch.Workspaces == nil {
14 return15 return
@@ -25,7 +26,7 @@ func (c *Core) publishWorkspaces() {
25 if name == "" {26 if name == "" {
26 name = strconv.Itoa(i + 1)27 name = strconv.Itoa(i + 1)
27 }28 }
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+ 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})
29 }30 }
30 snapshot.Outputs = append(snapshot.Outputs, out)31 snapshot.Outputs = append(snapshot.Outputs, out)
31 }32 }
M internal/core/workspaces_test.go +15 -2
@@ -11,14 +11,16 @@ import (
1111
1212 func TestWorkspaceSnapshotsAndActivation(t *testing.T) {
1313 cfg := config.Defaults()
14+ cfg.Workspaces = append(cfg.Workspaces, ports.WorkspaceConfig{Name: "dev", Hidden: true})
1415 client := make(chan ports.ClientEvent, 16)
1516 input := make(chan ports.InputEvent, 8)
1617 output := make(chan ports.OutputEvent, 8)
1718 commands := make(chan ports.ClientCommand, 32)
1819 snapshots := make(chan ports.Workspaces, 1)
1920 scenes := make(chan []ports.Scene, 1)
21+ state := make(chan ports.State, 1)
2022 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})
23+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Workspaces: snapshots, State: state, Scenes: scenes, Spawn: spawn})
2224 if err != nil {
2325 t.Fatal(err)
2426 }
@@ -36,9 +38,13 @@ func TestWorkspaceSnapshotsAndActivation(t *testing.T) {
3638 for len(first.Outputs) == 0 {
3739 first = receive(t, snapshots)
3840 }
39- if len(first.Outputs) != 1 || len(first.Outputs[0].Workspaces) != 1 || !first.Outputs[0].Workspaces[0].Active {
41+ if len(first.Outputs) != 1 || len(first.Outputs[0].Workspaces) < 2 || !first.Outputs[0].Workspaces[0].Active {
4042 t.Fatal(first)
4143 }
44+ hidden := first.Outputs[0].Workspaces[len(first.Outputs[0].Workspaces)-1]
45+ if hidden.Configured != "dev" || !hidden.Hidden {
46+ t.Fatalf("configured hidden workspace: %+v", hidden)
47+ }
4248 id := first.Outputs[0].Workspaces[0].ID
4349 client <- ports.WindowMapped{ID: 1}
4450 added := receive(t, snapshots)
@@ -51,7 +57,14 @@ func TestWorkspaceSnapshotsAndActivation(t *testing.T) {
5157 if len(two.Outputs) != 2 || two.Outputs[0].Workspaces[0].ID != id {
5258 t.Fatal(two)
5359 }
60+ // Activation on B also focuses B, without disturbing A's active workspace.
61+ bID := two.Outputs[1].Workspaces[0].ID
62+ client <- ports.WorkspaceActivate{ID: bID}
63+ for receive(t, state).Output != "B" {
64+ }
5465 client <- ports.WorkspaceActivate{ID: spare}
66+ for receive(t, state).Output != "A" {
67+ }
5568 active := receive(t, snapshots)
5669 if !active.Outputs[0].Workspaces[1].Active || active.Outputs[1].Workspaces[0].Active != true {
5770 t.Fatal(active)
@@ -11,14 +11,16 @@ import (
11 11
12 func TestWorkspaceSnapshotsAndActivation(t *testing.T) {12 func TestWorkspaceSnapshotsAndActivation(t *testing.T) {
13 cfg := config.Defaults()13 cfg := config.Defaults()
14+ cfg.Workspaces = append(cfg.Workspaces, ports.WorkspaceConfig{Name: "dev", Hidden: true})
14 client := make(chan ports.ClientEvent, 16)15 client := make(chan ports.ClientEvent, 16)
15 input := make(chan ports.InputEvent, 8)16 input := make(chan ports.InputEvent, 8)
16 output := make(chan ports.OutputEvent, 8)17 output := make(chan ports.OutputEvent, 8)
17 commands := make(chan ports.ClientCommand, 32)18 commands := make(chan ports.ClientCommand, 32)
18 snapshots := make(chan ports.Workspaces, 1)19 snapshots := make(chan ports.Workspaces, 1)
19 scenes := make(chan []ports.Scene, 1)20 scenes := make(chan []ports.Scene, 1)
21+ state := make(chan ports.State, 1)
20 spawn := make(chan ports.SpawnRequest, 8)22 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})23+ c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Workspaces: snapshots, State: state, Scenes: scenes, Spawn: spawn})
22 if err != nil {24 if err != nil {
23 t.Fatal(err)25 t.Fatal(err)
24 }26 }
@@ -36,9 +38,13 @@ func TestWorkspaceSnapshotsAndActivation(t *testing.T) {
36 for len(first.Outputs) == 0 {38 for len(first.Outputs) == 0 {
37 first = receive(t, snapshots)39 first = receive(t, snapshots)
38 }40 }
39- if len(first.Outputs) != 1 || len(first.Outputs[0].Workspaces) != 1 || !first.Outputs[0].Workspaces[0].Active {41+ if len(first.Outputs) != 1 || len(first.Outputs[0].Workspaces) < 2 || !first.Outputs[0].Workspaces[0].Active {
40 t.Fatal(first)42 t.Fatal(first)
41 }43 }
44+ hidden := first.Outputs[0].Workspaces[len(first.Outputs[0].Workspaces)-1]
45+ if hidden.Configured != "dev" || !hidden.Hidden {
46+ t.Fatalf("configured hidden workspace: %+v", hidden)
47+ }
42 id := first.Outputs[0].Workspaces[0].ID48 id := first.Outputs[0].Workspaces[0].ID
43 client <- ports.WindowMapped{ID: 1}49 client <- ports.WindowMapped{ID: 1}
44 added := receive(t, snapshots)50 added := receive(t, snapshots)
@@ -51,7 +57,14 @@ func TestWorkspaceSnapshotsAndActivation(t *testing.T) {
51 if len(two.Outputs) != 2 || two.Outputs[0].Workspaces[0].ID != id {57 if len(two.Outputs) != 2 || two.Outputs[0].Workspaces[0].ID != id {
52 t.Fatal(two)58 t.Fatal(two)
53 }59 }
60+ // Activation on B also focuses B, without disturbing A's active workspace.
61+ bID := two.Outputs[1].Workspaces[0].ID
62+ client <- ports.WorkspaceActivate{ID: bID}
63+ for receive(t, state).Output != "B" {
64+ }
54 client <- ports.WorkspaceActivate{ID: spare}65 client <- ports.WorkspaceActivate{ID: spare}
66+ for receive(t, state).Output != "A" {
67+ }
55 active := receive(t, snapshots)68 active := receive(t, snapshots)
56 if !active.Outputs[0].Workspaces[1].Active || active.Outputs[1].Workspaces[0].Active != true {69 if !active.Outputs[0].Workspaces[1].Active || active.Outputs[1].Workspaces[0].Active != true {
57 t.Fatal(active)70 t.Fatal(active)
M internal/ports/messages.go +1 -0
@@ -110,6 +110,7 @@ type WorkspaceOutput struct {
110110 }
111111 type WorkspaceInfo struct {
112112 ID uint64
113+ Configured string // stable configured name; empty for dynamic workspaces
113114 Name string
114115 Index int // zero-based vertical coordinate
115116 Active, Hidden bool
@@ -110,6 +110,7 @@ type WorkspaceOutput struct {
110 }110 }
111 type WorkspaceInfo struct {111 type WorkspaceInfo struct {
112 ID uint64112 ID uint64
113+ Configured string // stable configured name; empty for dynamic workspaces
113 Name string114 Name string
114 Index int // zero-based vertical coordinate115 Index int // zero-based vertical coordinate
115 Active, Hidden bool116 Active, Hidden bool

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 26

Tags 10

↑↓ moveenter openesc close