Merge pull request #22: feat(wayland): support ext-workspace-v1
# Conflicts: # internal/core/core.go
14 files · +928 -29
M README.md +2 -0
| @@ -161,6 +161,8 @@ startup = swayidle -w timeout 300 wlopm-off resume wlopm-on | |||
| 161 | 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 | 166 | ## State for scripts | |
| 165 | 167 | ||
| 166 | 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: | |
| @@ -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 scripts | 166 | ## 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 { | |||
| 13 | 13 | func() error { return registerLayer(d, s) }, | |
| 14 | 14 | func() error { return registerXDGOutput(d, s) }, | |
| 15 | 15 | func() error { return registerOutputManagement(d, s) }, | |
| 16 | + | func() error { return registerWorkspaces(d, s) }, | |
| 16 | 17 | func() error { return registerDecoration(d, s) }, | |
| 17 | 18 | func() error { return registerScale(d, s) }, | |
| 18 | 19 | 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) { | |||
| 139 | 139 | o.resources = append(o.resources, r) | |
| 140 | 140 | r.OnDestroy = func() { o.resources = removeItem(o.resources, r) } | |
| 141 | 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 | 148 | if err != nil { | |
| 144 | 149 | 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 { | |||
| 36 | 36 | ||
| 37 | 37 | // Channels carries client notifications and commands. Events may be unbuffered. | |
| 38 | 38 | 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 | |
| 42 | 43 | // Cursors receives the cursor the client under the pointer asks for. | |
| 43 | 44 | Cursors chan<- ports.CursorChange | |
| 44 | 45 | // Presented paces frame callbacks on the outputs' page flips; outputs | |
| @@ -152,20 +153,22 @@ type Server struct { | |||
| 152 | 153 | shortcutWindows, idleWindows map[ports.WindowID]bool | |
| 153 | 154 | // idleNotes and powers are the idle notifications and output power | |
| 154 | 155 | // 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 | |
| 169 | 172 | // cursorSurface is the wl_pointer.set_cursor surface in use. | |
| 170 | 173 | cursorSurface *surface | |
| 171 | 174 | // cursorMu guards only the latest cursor change for forwardCursors. | |
| @@ -289,7 +292,8 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 289 | 292 | s.started = time.Now() | |
| 290 | 293 | s.ctx = ctx | |
| 291 | 294 | var wg sync.WaitGroup | |
| 292 | - | wg.Add(9) | |
| 295 | + | wg.Add(10) | |
| 296 | + | go func() { defer wg.Done(); s.forwardWorkspaces(ctx) }() | |
| 293 | 297 | go func() { defer wg.Done(); s.forward(ctx) }() | |
| 294 | 298 | go func() { defer wg.Done(); s.forwardCursors(ctx) }() | |
| 295 | 299 | 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.ClientEvent | 39 | + | Events chan<- ports.ClientEvent |
| 40 | - | Contents chan<- ports.SurfaceContent | 40 | + | Contents chan<- ports.SurfaceContent |
| 41 | - | Commands <-chan ports.ClientCommand | 41 | + | 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.CursorChange | 44 | Cursors chan<- ports.CursorChange | ||
| 44 | // Presented paces frame callbacks on the outputs' page flips; outputs | 45 | // Presented paces frame callbacks on the outputs' page flips; outputs | ||
| @@ -152,20 +153,22 @@ type Server struct { | |||||
| 152 | shortcutWindows, idleWindows map[ports.WindowID]bool | 153 | shortcutWindows, idleWindows map[ports.WindowID]bool | ||
| 153 | // idleNotes and powers are the idle notifications and output power | 154 | // 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 []*idleNotification | 156 | + | idleNotes []*idleNotification |
| 156 | - | powers []*outputPower | 157 | + | powers []*outputPower |
| 157 | - | outputsOff map[string]bool | 158 | + | outputsOff map[string]bool |
| 158 | - | contentNotify chan struct{} | 159 | + | contentNotify chan struct{} |
| 159 | - | contentReady chan struct{} | 160 | + | contentReady chan struct{} |
| 160 | - | outputs []*output | 161 | + | outputs []*output |
| 161 | - | outputManagers []*outputManager | 162 | + | outputManagers []*outputManager |
| 162 | - | outputHeads ports.OutputHeads | 163 | + | workspaceManagers []*workspaceManager |
| 163 | - | outputPlaces ports.Layout | 164 | + | workspaceSnapshot ports.Workspaces |
| 164 | - | managementSerial uint32 | 165 | + | outputHeads ports.OutputHeads |
| 165 | - | nextOutputApply uint64 | 166 | + | outputPlaces ports.Layout |
| 166 | - | outputReplies map[uint64]*outputConfiguration | 167 | + | managementSerial uint32 |
| 167 | - | focusedOutput string | 168 | + | nextOutputApply uint64 |
| 168 | - | fractions map[*surface]*fractionalscale.WpFractionalScaleV1 | 169 | + | 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 *surface | 173 | 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 = ctx | 293 | s.ctx = ctx | ||
| 291 | var wg sync.WaitGroup | 294 | 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) | |||
| 85 | 85 | layouts := make(chan ports.Layout, 1) | |
| 86 | 86 | constraints := make(chan ports.PointerConstraint, 1) | |
| 87 | 87 | states := make(chan ports.State, 1) | |
| 88 | + | workspaces := make(chan ports.Workspaces, 1) | |
| 88 | 89 | configErrors := make(chan error, 8) | |
| 89 | 90 | renderScenes := make(chan []ports.Scene, 1) | |
| 90 | 91 | contents := make(chan ports.SurfaceContent, 64) | |
| @@ -99,7 +100,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 99 | 100 | appliedOutput := make(chan ports.OutputApplied, 8) | |
| 100 | 101 | backendConfig := make(chan ports.Config, 8) | |
| 101 | 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 | 104 | c, err := core.New(opts.Config, ch) | |
| 104 | 105 | if err != nil { | |
| 105 | 106 | return err | |
| @@ -113,7 +114,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 113 | 114 | // Every output renders on the same GPU: its formats are the clients'. | |
| 114 | 115 | dmabuf := vulkan.Probe() | |
| 115 | 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 | 118 | if err != nil { | |
| 118 | 119 | km.Close() | |
| 119 | 120 | 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 err | 106 | 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 err | 120 | return err | ||
M internal/core/core.go +28 -7
| @@ -36,7 +36,8 @@ type Channels struct { | |||
| 36 | 36 | ConfigErrors chan<- error | |
| 37 | 37 | // State, when set, receives a snapshot for scripts whenever it changes, | |
| 38 | 38 | // latest first (capacity 1, drained like Scenes). | |
| 39 | - | State chan ports.State | |
| 39 | + | State chan ports.State | |
| 40 | + | Workspaces chan ports.Workspaces | |
| 40 | 41 | // Terminal enables automatic terminal opening according to terminal.auto-open. | |
| 41 | 42 | Terminal bool | |
| 42 | 43 | } | |
| @@ -45,6 +46,7 @@ type binding struct { | |||
| 45 | 46 | key string | |
| 46 | 47 | } | |
| 47 | 48 | type Core struct { | |
| 49 | + | nextWorkspaceID uint64 | |
| 48 | 50 | ch Channels | |
| 49 | 51 | cfg ports.Config | |
| 50 | 52 | screens []*screen | |
| @@ -77,9 +79,10 @@ type Core struct { | |||
| 77 | 79 | terms map[string]*termSpawn | |
| 78 | 80 | firstTerminalResolved bool | |
| 79 | 81 | // 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 | |
| 83 | 86 | // popups are the placed xdg_popups; popupOrder stacks them, oldest first. | |
| 84 | 87 | popups map[WindowID]*popupState | |
| 85 | 88 | popupOrder []WindowID | |
| @@ -285,11 +288,12 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error { | |||
| 285 | 288 | return nil | |
| 286 | 289 | } | |
| 287 | 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) { | |
| 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") | |
| 290 | 293 | } | |
| 291 | 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 | 297 | if err := c.apply(cfg); err != nil { | |
| 294 | 298 | return nil, err | |
| 295 | 299 | } | |
| @@ -481,6 +485,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 481 | 485 | c.resolveConstraint() | |
| 482 | 486 | latest(c.ch.Scenes, scenes) | |
| 483 | 487 | c.publishState() | |
| 488 | + | c.publishWorkspaces() | |
| 484 | 489 | return nil | |
| 485 | 490 | } | |
| 486 | 491 | ||
| @@ -649,6 +654,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 649 | 654 | return nil | |
| 650 | 655 | } | |
| 651 | 656 | c.publishState() | |
| 657 | + | c.publishWorkspaces() | |
| 652 | 658 | for { | |
| 653 | 659 | select { | |
| 654 | 660 | case <-ctx.Done(): | |
| @@ -743,6 +749,21 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 743 | 749 | if s, _ := c.screenOf(v.ID); s != nil { | |
| 744 | 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 | 767 | case ports.WindowActivate: | |
| 747 | 768 | if c.activate(ctx, v.ID) != nil { | |
| 748 | 769 | return nil | |
| @@ -36,7 +36,8 @@ type Channels struct { | |||||
| 36 | ConfigErrors chan<- error | 36 | 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.State | 39 | + | 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 bool | 42 | Terminal bool | ||
| 42 | } | 43 | } | ||
| @@ -45,6 +46,7 @@ type binding struct { | |||||
| 45 | key string | 46 | key string | ||
| 46 | } | 47 | } | ||
| 47 | type Core struct { | 48 | type Core struct { | ||
| 49 | + | nextWorkspaceID uint64 | |||
| 48 | ch Channels | 50 | ch Channels | ||
| 49 | cfg ports.Config | 51 | cfg ports.Config | ||
| 50 | screens []*screen | 52 | screens []*screen | ||
| @@ -77,9 +79,10 @@ type Core struct { | |||||
| 77 | terms map[string]*termSpawn | 79 | terms map[string]*termSpawn | ||
| 78 | firstTerminalResolved bool | 80 | 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.WindowMapped | 82 | + | clients map[WindowID]ports.WindowMapped |
| 81 | - | inputRegions map[WindowID]ports.InputRegionChanged | 83 | + | inputRegions map[WindowID]ports.InputRegionChanged |
| 82 | - | sentState ports.State | 84 | + | 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]*popupState | 87 | popups map[WindowID]*popupState | ||
| 85 | popupOrder []WindowID | 88 | popupOrder []WindowID | ||
| @@ -285,11 +288,12 @@ func (c *Core) spawnSlots(ctx context.Context, shown bool) error { | |||||
| 285 | return nil | 288 | 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, err | 298 | 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 nil | 489 | return nil | ||
| 485 | } | 490 | } | ||
| 486 | 491 | ||||
| @@ -649,6 +654,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 649 | return nil | 654 | 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 nil | 769 | return nil | ||
M internal/core/layout.go +2 -0
| @@ -114,6 +114,8 @@ const ( | |||
| 114 | 114 | ) | |
| 115 | 115 | ||
| 116 | 116 | type Workspace struct { | |
| 117 | + | // ID stays with the workspace across reorder and monitor moves. | |
| 118 | + | ID uint64 | |
| 117 | 119 | // Name is set for workspaces declared in config; empty for dynamic ones. | |
| 118 | 120 | Name string | |
| 119 | 121 | 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 string | 120 | Name string | ||
| 119 | Overflow Overflow | 121 | Overflow Overflow | ||
M internal/core/monitor.go +8 -1
| @@ -21,6 +21,7 @@ type Monitor struct { | |||
| 21 | 21 | shown *Workspace // hidden workspace on screen, nil for Workspaces[Active] | |
| 22 | 22 | back *Workspace // where a named workspace toggle returns to | |
| 23 | 23 | template Workspace | |
| 24 | + | nextID *uint64 // shared by the monitors of one Core; owner goroutine only | |
| 24 | 25 | named []NamedWorkspace | |
| 25 | 26 | // followMove shows the target workspace after a move to it. | |
| 26 | 27 | followMove bool | |
| @@ -39,7 +40,11 @@ type NamedWorkspace struct { | |||
| 39 | 40 | func NewMonitor() *Monitor { return newMonitor("", "") } | |
| 40 | 41 | ||
| 41 | 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 | 48 | m.normalize() | |
| 44 | 49 | return m | |
| 45 | 50 | } | |
| @@ -119,6 +124,8 @@ func (m *Monitor) newWorkspace() *Workspace { | |||
| 119 | 124 | w := m.template | |
| 120 | 125 | w.presets = append([]Width(nil), m.template.presets...) | |
| 121 | 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 | 129 | return &w | |
| 123 | 130 | } | |
| 124 | 131 | ||
| @@ -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 to | 22 | back *Workspace // where a named workspace toggle returns to | ||
| 23 | template Workspace | 23 | template Workspace | ||
| 24 | + | nextID *uint64 // shared by the monitors of one Core; owner goroutine only | |||
| 24 | named []NamedWorkspace | 25 | 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 bool | 27 | 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 m | 49 | return m | ||
| 45 | } | 50 | } | ||
| @@ -119,6 +124,8 @@ func (m *Monitor) newWorkspace() *Workspace { | |||||
| 119 | w := m.template | 124 | 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 &w | 129 | return &w | ||
| 123 | } | 130 | } | ||
| 124 | 131 | ||||
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() | ||
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 } | |||
| 107 | 107 | ||
| 108 | 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 | 130 | // WindowAppID carries wayland → core an app ID set after the window mapped. | |
| 111 | 131 | type WindowAppID struct { | |
| 112 | 132 | 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 WindowID | 132 | ID WindowID | ||