fix(wayland): correct workspace IDs and event batches
- commit
- ab9dbeede788e1ba13ba48a810fc9853c1d8a329
- author
- brice · · 2026-09-27 13:51 UTC
- parent
- e16ebd1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 140 | 140 | r.OnDestroy = func() { o.resources = removeItem(o.resources, r) } | |
| 141 | 141 | o.sendAll(r) | |
| 142 | 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 | 148 | 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/binary" | |
| 6 | 6 | "reflect" | |
| 7 | - | "strconv" | |
| 8 | 7 | ||
| 9 | 8 | "github.com/bnema/neferwl/internal/ports" | |
| 10 | 9 | ext "github.com/bnema/purego-libwayland/protocol/extworkspace" | |
| @@ -86,14 +85,14 @@ func (m *workspaceManager) outputResource(name string) *wayland.Output { | |||
| 86 | 85 | } | |
| 87 | 86 | return nil | |
| 88 | 87 | } | |
| 89 | - | func (m *workspaceManager) outputBound(name string) { | |
| 88 | + | func (m *workspaceManager) outputBound(name string) bool { | |
| 90 | 89 | g := m.groups[name] | |
| 91 | 90 | if g == nil || !g.res.Alive() { | |
| 92 | - | return | |
| 91 | + | return false | |
| 93 | 92 | } | |
| 94 | 93 | r := m.outputResource(name) | |
| 95 | 94 | if r == g.output { | |
| 96 | - | return | |
| 95 | + | return false | |
| 97 | 96 | } | |
| 98 | 97 | if g.output != nil && g.output.Alive() { | |
| 99 | 98 | g.res.SendOutputLeave(g.output) | |
| @@ -102,7 +101,7 @@ func (m *workspaceManager) outputBound(name string) { | |||
| 102 | 101 | if r != nil { | |
| 103 | 102 | g.res.SendOutputEnter(r) | |
| 104 | 103 | } | |
| 105 | - | m.res.SendDone() | |
| 104 | + | return true | |
| 106 | 105 | } | |
| 107 | 106 | func (s *Server) forwardWorkspaces(ctx context.Context) { | |
| 108 | 107 | for { | |
| @@ -182,6 +181,9 @@ func (m *workspaceManager) update(snapshot ports.Workspaces) { | |||
| 182 | 181 | h = &workspaceHandle{res: r} | |
| 183 | 182 | m.handles[w.ID] = h | |
| 184 | 183 | m.res.SendWorkspace(r) | |
| 184 | + | if w.Configured != "" { | |
| 185 | + | r.SendId(w.Configured) | |
| 186 | + | } | |
| 185 | 187 | r.SendCapabilities(uint32(ext.ExtWorkspaceHandleV1WorkspaceCapabilitiesActivate)) | |
| 186 | 188 | } | |
| 187 | 189 | if h.group != out.Name { | |
| @@ -210,9 +212,6 @@ func (m *workspaceManager) update(snapshot ports.Workspaces) { | |||
| 210 | 212 | } | |
| 211 | 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 | 215 | h.info = w | |
| 217 | 216 | } | |
| 218 | 217 | } | |
| @@ -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 nil | 86 | 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 | - | return | 91 | + | 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 | - | return | 95 | + | 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] = h | 182 | 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 = w | 215 | h.info = w | ||
| 217 | } | 216 | } | ||
| 218 | } | 217 | } | ||
M internal/adapters/wayland/workspaces_test.go +118 -8
| @@ -13,15 +13,16 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | type workspaceEvents struct { | |
| 15 | 15 | wlturbo.BaseProxy | |
| 16 | - | client *wlturbo.Display | |
| 17 | - | events chan [2]uint32 | |
| 16 | + | client *wlturbo.Display | |
| 17 | + | events chan [2]uint32 | |
| 18 | + | idEvents chan string | |
| 18 | 19 | } | |
| 19 | 20 | ||
| 20 | 21 | func (p *workspaceEvents) Dispatch(e *wlturbo.Event) { | |
| 21 | 22 | v := [2]uint32{uint32(e.Opcode), 0} | |
| 22 | 23 | if e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup) || e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspace) { | |
| 23 | 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 | 26 | child.SetID(v[1]) | |
| 26 | 27 | p.client.Context().Register(child) | |
| 27 | 28 | } | |
| @@ -30,8 +31,9 @@ func (p *workspaceEvents) Dispatch(e *wlturbo.Event) { | |||
| 30 | 31 | ||
| 31 | 32 | type workspaceChildEvents struct { | |
| 32 | 33 | wlturbo.BaseProxy | |
| 33 | - | events chan [2]uint32 | |
| 34 | - | group bool | |
| 34 | + | events chan [2]uint32 | |
| 35 | + | group bool | |
| 36 | + | idEvents chan string | |
| 35 | 37 | } | |
| 36 | 38 | ||
| 37 | 39 | func (p *workspaceChildEvents) Dispatch(e *wlturbo.Event) { | |
| @@ -48,6 +50,8 @@ func (p *workspaceChildEvents) Dispatch(e *wlturbo.Event) { | |||
| 48 | 50 | v[1] = e.Uint32() | |
| 49 | 51 | case !p.group && (e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventState) || e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventCapabilities)): | |
| 50 | 52 | v[1] = e.Uint32() | |
| 53 | + | case !p.group && e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventId): | |
| 54 | + | p.idEvents <- e.String() | |
| 51 | 55 | } | |
| 52 | 56 | p.events <- v | |
| 53 | 57 | } | |
| @@ -74,7 +78,7 @@ func TestWorkspaceProtocol(t *testing.T) { | |||
| 74 | 78 | t.Fatal("display stopped") | |
| 75 | 79 | } | |
| 76 | 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 | 82 | p.SetID(managerID) | |
| 79 | 83 | c.Context().Register(p) | |
| 80 | 84 | var handle uint32 | |
| @@ -92,9 +96,15 @@ func TestWorkspaceProtocol(t *testing.T) { | |||
| 92 | 96 | if handle == 0 { | |
| 93 | 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 | 104 | out := bindProtocol(t, c, "wl_output") | |
| 96 | 105 | registerProtocol(t, c, out) | |
| 97 | 106 | entered := false | |
| 107 | + | doneCount := 0 | |
| 98 | 108 | for i := 0; i < 8 && !entered; i++ { | |
| 99 | 109 | if err := c.Roundtrip(); err != nil { | |
| 100 | 110 | t.Fatal(err) | |
| @@ -104,10 +114,24 @@ func TestWorkspaceProtocol(t *testing.T) { | |||
| 104 | 114 | if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, out} { | |
| 105 | 115 | 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 { | |
| 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") | |
| 111 | 135 | } | |
| 112 | 136 | requestProtocol(t, c, handle, ext.ExtWorkspaceHandleV1RequestActivate) | |
| 113 | 137 | if err := c.Roundtrip(); err != nil { | |
| @@ -163,3 +187,89 @@ func TestWorkspaceProtocol(t *testing.T) { | |||
| 163 | 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 | + | } | |
| @@ -13,15 +13,16 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | type workspaceEvents struct { | 14 | type workspaceEvents struct { | ||
| 15 | wlturbo.BaseProxy | 15 | wlturbo.BaseProxy | ||
| 16 | - | client *wlturbo.Display | 16 | + | client *wlturbo.Display |
| 17 | - | events chan [2]uint32 | 17 | + | 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.BaseProxy | 33 | wlturbo.BaseProxy | ||
| 33 | - | events chan [2]uint32 | 34 | + | events chan [2]uint32 |
| 34 | - | group bool | 35 | + | 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 <- v | 56 | 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 uint32 | 84 | 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 := false | 106 | 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 = true | 115 | 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 { | |||
| 47 | 47 | key string | |
| 48 | 48 | } | |
| 49 | 49 | type Core struct { | |
| 50 | + | nextWorkspaceID uint64 | |
| 50 | 51 | ch Channels | |
| 51 | 52 | cfg ports.Config | |
| 52 | 53 | screens []*screen | |
| @@ -289,7 +290,8 @@ func New(cfg ports.Config, ch Channels) (*Core, error) { | |||
| 289 | 290 | return nil, fmt.Errorf("scenes, layouts, constraints and state must have capacity 1") | |
| 290 | 291 | } | |
| 291 | 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 | 295 | if err := c.apply(cfg); err != nil { | |
| 294 | 296 | return nil, err | |
| 295 | 297 | } | |
| @@ -719,12 +721,13 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 719 | 721 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | |
| 720 | 722 | } | |
| 721 | 723 | case ports.WorkspaceActivate: | |
| 722 | - | for _, sc := range c.screens { | |
| 724 | + | for i, sc := range c.screens { | |
| 723 | 725 | if sc.name() == "" { | |
| 724 | 726 | continue | |
| 725 | 727 | } | |
| 726 | 728 | for _, w := range sc.mon.all() { | |
| 727 | 729 | if w.ID == v.ID { | |
| 730 | + | c.focusScreen = i | |
| 728 | 731 | sc.mon.show(w) | |
| 729 | 732 | break | |
| 730 | 733 | } | |
| @@ -47,6 +47,7 @@ type binding struct { | |||||
| 47 | key string | 47 | key string | ||
| 48 | } | 48 | } | ||
| 49 | type Core struct { | 49 | type Core struct { | ||
| 50 | + | nextWorkspaceID uint64 | |||
| 50 | ch Channels | 51 | ch Channels | ||
| 51 | cfg ports.Config | 52 | cfg ports.Config | ||
| 52 | screens []*screen | 53 | 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, err | 296 | 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 | continue | 726 | 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 | break | 732 | break | ||
| 730 | } | 733 | } | ||
M internal/core/monitor.go +9 -8
| @@ -1,11 +1,6 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | - | import ( | |
| 4 | - | "slices" | |
| 5 | - | "sync/atomic" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | var nextWorkspaceID atomic.Uint64 | |
| 3 | + | import "slices" | |
| 9 | 4 | ||
| 10 | 5 | // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers | |
| 11 | 6 | // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits | |
| @@ -26,6 +21,7 @@ type Monitor struct { | |||
| 26 | 21 | shown *Workspace // hidden workspace on screen, nil for Workspaces[Active] | |
| 27 | 22 | back *Workspace // where a named workspace toggle returns to | |
| 28 | 23 | template Workspace | |
| 24 | + | nextID *uint64 // shared by the monitors of one Core; owner goroutine only | |
| 29 | 25 | named []NamedWorkspace | |
| 30 | 26 | // followMove shows the target workspace after a move to it. | |
| 31 | 27 | followMove bool | |
| @@ -45,7 +41,11 @@ type NamedWorkspace struct { | |||
| 45 | 41 | func NewMonitor() *Monitor { return newMonitor("", "") } | |
| 46 | 42 | ||
| 47 | 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 | 49 | m.normalize() | |
| 50 | 50 | return m | |
| 51 | 51 | } | |
| @@ -125,7 +125,8 @@ func (m *Monitor) newWorkspace() *Workspace { | |||
| 125 | 125 | w := m.template | |
| 126 | 126 | w.presets = append([]Width(nil), m.template.presets...) | |
| 127 | 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 | 130 | return &w | |
| 130 | 131 | } | |
| 131 | 132 | ||
| @@ -1,11 +1,6 @@ | |||||
| 1 | package core | 1 | 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). Numbers | 5 | // 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 sits | 6 | // 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 to | 22 | back *Workspace // where a named workspace toggle returns to | ||
| 28 | template Workspace | 23 | template Workspace | ||
| 24 | + | nextID *uint64 // shared by the monitors of one Core; owner goroutine only | |||
| 29 | named []NamedWorkspace | 25 | 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 bool | 27 | 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 m | 50 | return m | ||
| 51 | } | 51 | } | ||
| @@ -125,7 +125,8 @@ func (m *Monitor) newWorkspace() *Workspace { | |||||
| 125 | w := m.template | 125 | 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 &w | 130 | return &w | ||
| 130 | } | 131 | } | ||
| 131 | 132 | ||||
M internal/core/screens.go +1 -1
| @@ -186,7 +186,7 @@ func (c *Core) addScreen(info ports.OutputInfo) { | |||
| 186 | 186 | s = c.screens[0] | |
| 187 | 187 | s.info = info | |
| 188 | 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 | 190 | c.screens = append(c.screens, s) | |
| 191 | 191 | } | |
| 192 | 192 | 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 = info | 187 | 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 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "github.com/bnema/neferwl/internal/ports" | |
| 5 | 4 | "reflect" | |
| 6 | 5 | "strconv" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/ports" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | // 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. | |
| 12 | 13 | func (c *Core) publishWorkspaces() { | |
| 13 | 14 | if c.ch.Workspaces == nil { | |
| 14 | 15 | return | |
| @@ -25,7 +26,7 @@ func (c *Core) publishWorkspaces() { | |||
| 25 | 26 | if name == "" { | |
| 26 | 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 | 31 | snapshot.Outputs = append(snapshot.Outputs, out) | |
| 31 | 32 | } | |
| @@ -1,14 +1,15 @@ | |||||
| 1 | package core | 1 | 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 hidden | 10 | // publishWorkspaces describes the numbered vertical stack and configured hidden | ||
| 10 | - | // workspaces. Empty numbered spares are hidden unless currently shown; unlike | 11 | + | // 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 | return | 15 | 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 ( | |||
| 11 | 11 | ||
| 12 | 12 | func TestWorkspaceSnapshotsAndActivation(t *testing.T) { | |
| 13 | 13 | cfg := config.Defaults() | |
| 14 | + | cfg.Workspaces = append(cfg.Workspaces, ports.WorkspaceConfig{Name: "dev", Hidden: true}) | |
| 14 | 15 | client := make(chan ports.ClientEvent, 16) | |
| 15 | 16 | input := make(chan ports.InputEvent, 8) | |
| 16 | 17 | output := make(chan ports.OutputEvent, 8) | |
| 17 | 18 | commands := make(chan ports.ClientCommand, 32) | |
| 18 | 19 | snapshots := make(chan ports.Workspaces, 1) | |
| 19 | 20 | scenes := make(chan []ports.Scene, 1) | |
| 21 | + | state := make(chan ports.State, 1) | |
| 20 | 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 | 24 | if err != nil { | |
| 23 | 25 | t.Fatal(err) | |
| 24 | 26 | } | |
| @@ -36,9 +38,13 @@ func TestWorkspaceSnapshotsAndActivation(t *testing.T) { | |||
| 36 | 38 | for len(first.Outputs) == 0 { | |
| 37 | 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 | 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 | 48 | id := first.Outputs[0].Workspaces[0].ID | |
| 43 | 49 | client <- ports.WindowMapped{ID: 1} | |
| 44 | 50 | added := receive(t, snapshots) | |
| @@ -51,7 +57,14 @@ func TestWorkspaceSnapshotsAndActivation(t *testing.T) { | |||
| 51 | 57 | if len(two.Outputs) != 2 || two.Outputs[0].Workspaces[0].ID != id { | |
| 52 | 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 | 65 | client <- ports.WorkspaceActivate{ID: spare} | |
| 66 | + | for receive(t, state).Output != "A" { | |
| 67 | + | } | |
| 55 | 68 | active := receive(t, snapshots) | |
| 56 | 69 | if !active.Outputs[0].Workspaces[1].Active || active.Outputs[1].Workspaces[0].Active != true { | |
| 57 | 70 | 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].ID | 48 | 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 { | |||
| 110 | 110 | } | |
| 111 | 111 | type WorkspaceInfo struct { | |
| 112 | 112 | ID uint64 | |
| 113 | + | Configured string // stable configured name; empty for dynamic workspaces | |
| 113 | 114 | Name string | |
| 114 | 115 | Index int // zero-based vertical coordinate | |
| 115 | 116 | Active, Hidden bool | |
| @@ -110,6 +110,7 @@ type WorkspaceOutput struct { | |||||
| 110 | } | 110 | } | ||
| 111 | type WorkspaceInfo struct { | 111 | type WorkspaceInfo struct { | ||
| 112 | ID uint64 | 112 | ID uint64 | ||
| 113 | + | Configured string // stable configured name; empty for dynamic workspaces | |||
| 113 | Name string | 114 | Name string | ||
| 114 | Index int // zero-based vertical coordinate | 115 | Index int // zero-based vertical coordinate | ||
| 115 | Active, Hidden bool | 116 | Active, Hidden bool | ||