feat(wayland): support ext-workspace-v1
- commit
- e16ebd124795c1ebe0ead9e50ea9617592290317
- author
- brice · · 2026-09-27 13:38 UTC
- parent
- 5731344
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +596 -24
M README.md +2 -0
| @@ -159,6 +159,8 @@ startup = swayidle -w timeout 300 wlopm-off resume wlopm-on | |||
| 159 | 159 | exec wlopm --off '*' | |
| 160 | 160 | ``` | |
| 161 | 161 | ||
| 162 | + | NeferWL supports `ext_workspace_manager_v1` for bars such as Waybar 0.13+ (`ext/workspaces`) and ironbar. Bars receive workspace updates and can switch workspaces without polling. | |
| 163 | + | ||
| 162 | 164 | ## State for scripts | |
| 163 | 165 | ||
| 164 | 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: | |
| @@ -159,6 +159,8 @@ startup = swayidle -w timeout 300 wlopm-off resume wlopm-on | |||||
| 159 | exec wlopm --off '*' | 159 | exec wlopm --off '*' | ||
| 160 | ``` | 160 | ``` | ||
| 161 | 161 | ||||
| 162 | + | NeferWL supports `ext_workspace_manager_v1` for bars such as Waybar 0.13+ (`ext/workspaces`) and ironbar. Bars receive workspace updates and can switch workspaces without polling. | |||
| 163 | + | ||||
| 162 | ## State for scripts | 164 | ## State for scripts | ||
| 163 | 165 | ||||
| 164 | While it runs, NeferWL writes its state to `$XDG_RUNTIME_DIR/neferwl/<wayland socket>.json` and passes that path to the programs it starts as `NEFERWL_STATE`. The file lists: | 166 | While it runs, NeferWL writes its state to `$XDG_RUNTIME_DIR/neferwl/<wayland socket>.json` and passes that path to the programs it starts as `NEFERWL_STATE`. The file lists: | ||
M internal/adapters/wayland/globals.go +1 -0
| @@ -13,6 +13,7 @@ func registerGlobals(d *server.Display, o Options, s *Server) error { | |||
| 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 +3 -0
| @@ -139,6 +139,9 @@ 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 | + | m.outputBound(o.name()) | |
| 144 | + | } | |
| 142 | 145 | }) | |
| 143 | 146 | if err != nil { | |
| 144 | 147 | s.log.Warn().Err(err).Str("output", p.Info.Name).Msg("add wl_output") | |
| @@ -139,6 +139,9 @@ func (s *Server) addOutput(p ports.OutputPlacement) { | |||||
| 139 | o.resources = append(o.resources, r) | 139 | o.resources = append(o.resources, r) | ||
| 140 | r.OnDestroy = func() { o.resources = removeItem(o.resources, r) } | 140 | r.OnDestroy = func() { o.resources = removeItem(o.resources, r) } | ||
| 141 | o.sendAll(r) | 141 | o.sendAll(r) | ||
| 142 | + | for _, m := range s.workspaceManagers { | |||
| 143 | + | m.outputBound(o.name()) | |||
| 144 | + | } | |||
| 142 | }) | 145 | }) | ||
| 143 | if err != nil { | 146 | if err != nil { | ||
| 144 | s.log.Warn().Err(err).Str("output", p.Info.Name).Msg("add wl_output") | 147 | s.log.Warn().Err(err).Str("output", p.Info.Name).Msg("add wl_output") | ||
M internal/adapters/wayland/server.go +22 -18
| @@ -37,9 +37,10 @@ type Options struct { | |||
| 37 | 37 | ||
| 38 | 38 | // Channels carries client notifications and commands. Events may be unbuffered. | |
| 39 | 39 | type Channels struct { | |
| 40 | - | Events chan<- ports.ClientEvent | |
| 41 | - | Contents chan<- ports.SurfaceContent | |
| 42 | - | Commands <-chan ports.ClientCommand | |
| 40 | + | Events chan<- ports.ClientEvent | |
| 41 | + | Contents chan<- ports.SurfaceContent | |
| 42 | + | Commands <-chan ports.ClientCommand | |
| 43 | + | Workspaces <-chan ports.Workspaces | |
| 43 | 44 | // Cursors receives the cursor the client under the pointer asks for. | |
| 44 | 45 | Cursors chan<- ports.CursorChange | |
| 45 | 46 | // Presented paces frame callbacks on the outputs' page flips; outputs | |
| @@ -148,20 +149,22 @@ type Server struct { | |||
| 148 | 149 | shortcutWindows, idleWindows map[ports.WindowID]bool | |
| 149 | 150 | // idleNotes and powers are the idle notifications and output power | |
| 150 | 151 | // objects (idle.go); outputsOff the outputs core turned off. | |
| 151 | - | idleNotes []*idleNotification | |
| 152 | - | powers []*outputPower | |
| 153 | - | outputsOff map[string]bool | |
| 154 | - | contentNotify chan struct{} | |
| 155 | - | contentReady chan struct{} | |
| 156 | - | outputs []*output | |
| 157 | - | outputManagers []*outputManager | |
| 158 | - | outputHeads ports.OutputHeads | |
| 159 | - | outputPlaces ports.Layout | |
| 160 | - | managementSerial uint32 | |
| 161 | - | nextOutputApply uint64 | |
| 162 | - | outputReplies map[uint64]*outputConfiguration | |
| 163 | - | focusedOutput string | |
| 164 | - | fractions map[*surface]*fractionalscale.WpFractionalScaleV1 | |
| 152 | + | idleNotes []*idleNotification | |
| 153 | + | powers []*outputPower | |
| 154 | + | outputsOff map[string]bool | |
| 155 | + | contentNotify chan struct{} | |
| 156 | + | contentReady chan struct{} | |
| 157 | + | outputs []*output | |
| 158 | + | outputManagers []*outputManager | |
| 159 | + | workspaceManagers []*workspaceManager | |
| 160 | + | workspaceSnapshot ports.Workspaces | |
| 161 | + | outputHeads ports.OutputHeads | |
| 162 | + | outputPlaces ports.Layout | |
| 163 | + | managementSerial uint32 | |
| 164 | + | nextOutputApply uint64 | |
| 165 | + | outputReplies map[uint64]*outputConfiguration | |
| 166 | + | focusedOutput string | |
| 167 | + | fractions map[*surface]*fractionalscale.WpFractionalScaleV1 | |
| 165 | 168 | // cursorSurface is the wl_pointer.set_cursor surface in use. | |
| 166 | 169 | cursorSurface *surface | |
| 167 | 170 | // cursorMu guards only the latest cursor change for forwardCursors. | |
| @@ -285,7 +288,8 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 285 | 288 | s.started = time.Now() | |
| 286 | 289 | s.ctx = ctx | |
| 287 | 290 | var wg sync.WaitGroup | |
| 288 | - | wg.Add(9) | |
| 291 | + | wg.Add(10) | |
| 292 | + | go func() { defer wg.Done(); s.forwardWorkspaces(ctx) }() | |
| 289 | 293 | go func() { defer wg.Done(); s.forward(ctx) }() | |
| 290 | 294 | go func() { defer wg.Done(); s.forwardCursors(ctx) }() | |
| 291 | 295 | go func() { defer wg.Done(); s.forwardContents(ctx) }() | |
| @@ -37,9 +37,10 @@ type Options struct { | |||||
| 37 | 37 | ||||
| 38 | // Channels carries client notifications and commands. Events may be unbuffered. | 38 | // Channels carries client notifications and commands. Events may be unbuffered. | ||
| 39 | type Channels struct { | 39 | type Channels struct { | ||
| 40 | - | Events chan<- ports.ClientEvent | 40 | + | Events chan<- ports.ClientEvent |
| 41 | - | Contents chan<- ports.SurfaceContent | 41 | + | Contents chan<- ports.SurfaceContent |
| 42 | - | Commands <-chan ports.ClientCommand | 42 | + | Commands <-chan ports.ClientCommand |
| 43 | + | Workspaces <-chan ports.Workspaces | |||
| 43 | // Cursors receives the cursor the client under the pointer asks for. | 44 | // Cursors receives the cursor the client under the pointer asks for. | ||
| 44 | Cursors chan<- ports.CursorChange | 45 | Cursors chan<- ports.CursorChange | ||
| 45 | // Presented paces frame callbacks on the outputs' page flips; outputs | 46 | // Presented paces frame callbacks on the outputs' page flips; outputs | ||
| @@ -148,20 +149,22 @@ type Server struct { | |||||
| 148 | shortcutWindows, idleWindows map[ports.WindowID]bool | 149 | shortcutWindows, idleWindows map[ports.WindowID]bool | ||
| 149 | // idleNotes and powers are the idle notifications and output power | 150 | // idleNotes and powers are the idle notifications and output power | ||
| 150 | // objects (idle.go); outputsOff the outputs core turned off. | 151 | // objects (idle.go); outputsOff the outputs core turned off. | ||
| 151 | - | idleNotes []*idleNotification | 152 | + | idleNotes []*idleNotification |
| 152 | - | powers []*outputPower | 153 | + | powers []*outputPower |
| 153 | - | outputsOff map[string]bool | 154 | + | outputsOff map[string]bool |
| 154 | - | contentNotify chan struct{} | 155 | + | contentNotify chan struct{} |
| 155 | - | contentReady chan struct{} | 156 | + | contentReady chan struct{} |
| 156 | - | outputs []*output | 157 | + | outputs []*output |
| 157 | - | outputManagers []*outputManager | 158 | + | outputManagers []*outputManager |
| 158 | - | outputHeads ports.OutputHeads | 159 | + | workspaceManagers []*workspaceManager |
| 159 | - | outputPlaces ports.Layout | 160 | + | workspaceSnapshot ports.Workspaces |
| 160 | - | managementSerial uint32 | 161 | + | outputHeads ports.OutputHeads |
| 161 | - | nextOutputApply uint64 | 162 | + | outputPlaces ports.Layout |
| 162 | - | outputReplies map[uint64]*outputConfiguration | 163 | + | managementSerial uint32 |
| 163 | - | focusedOutput string | 164 | + | nextOutputApply uint64 |
| 164 | - | fractions map[*surface]*fractionalscale.WpFractionalScaleV1 | 165 | + | outputReplies map[uint64]*outputConfiguration |
| 166 | + | focusedOutput string | |||
| 167 | + | fractions map[*surface]*fractionalscale.WpFractionalScaleV1 | |||
| 165 | // cursorSurface is the wl_pointer.set_cursor surface in use. | 168 | // cursorSurface is the wl_pointer.set_cursor surface in use. | ||
| 166 | cursorSurface *surface | 169 | cursorSurface *surface | ||
| 167 | // cursorMu guards only the latest cursor change for forwardCursors. | 170 | // cursorMu guards only the latest cursor change for forwardCursors. | ||
| @@ -285,7 +288,8 @@ func (s *Server) Run(ctx context.Context) error { | |||||
| 285 | s.started = time.Now() | 288 | s.started = time.Now() | ||
| 286 | s.ctx = ctx | 289 | s.ctx = ctx | ||
| 287 | var wg sync.WaitGroup | 290 | var wg sync.WaitGroup | ||
| 288 | - | wg.Add(9) | 291 | + | wg.Add(10) |
| 292 | + | go func() { defer wg.Done(); s.forwardWorkspaces(ctx) }() | |||
| 289 | go func() { defer wg.Done(); s.forward(ctx) }() | 293 | go func() { defer wg.Done(); s.forward(ctx) }() | ||
| 290 | go func() { defer wg.Done(); s.forwardCursors(ctx) }() | 294 | go func() { defer wg.Done(); s.forwardCursors(ctx) }() | ||
| 291 | go func() { defer wg.Done(); s.forwardContents(ctx) }() | 295 | go func() { defer wg.Done(); s.forwardContents(ctx) }() | ||
A internal/adapters/wayland/workspaces.go +232 -0
| @@ -0,0 +1,232 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/binary" | |
| 6 | + | "reflect" | |
| 7 | + | "strconv" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | ext "github.com/bnema/purego-libwayland/protocol/extworkspace" | |
| 11 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 12 | + | "github.com/bnema/purego-libwayland/server" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type workspaceManager struct { | |
| 16 | + | s *Server | |
| 17 | + | res *ext.ExtWorkspaceManagerV1 | |
| 18 | + | groups map[string]*workspaceGroup | |
| 19 | + | handles map[uint64]*workspaceHandle | |
| 20 | + | pending []uint64 | |
| 21 | + | } | |
| 22 | + | type workspaceGroup struct { | |
| 23 | + | res *ext.ExtWorkspaceGroupHandleV1 | |
| 24 | + | output *wayland.Output | |
| 25 | + | } | |
| 26 | + | type workspaceHandle struct { | |
| 27 | + | res *ext.ExtWorkspaceHandleV1 | |
| 28 | + | info ports.WorkspaceInfo | |
| 29 | + | group string | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func registerWorkspaces(d *server.Display, s *Server) error { | |
| 33 | + | return ext.NewExtWorkspaceManagerV1Global(d, 1, func(c server.Client, v, id uint32) { | |
| 34 | + | m := &workspaceManager{s: s, groups: map[string]*workspaceGroup{}, handles: map[uint64]*workspaceHandle{}} | |
| 35 | + | r, err := ext.NewExtWorkspaceManagerV1(c, int32(v), id, m) | |
| 36 | + | if err != nil { | |
| 37 | + | return | |
| 38 | + | } | |
| 39 | + | m.res = r | |
| 40 | + | s.workspaceManagers = append(s.workspaceManagers, m) | |
| 41 | + | r.OnDestroy = func() { s.workspaceManagers = removeItem(s.workspaceManagers, m) } | |
| 42 | + | m.update(s.workspaceSnapshot) | |
| 43 | + | }) | |
| 44 | + | } | |
| 45 | + | func (m *workspaceManager) Commit(*ext.ExtWorkspaceManagerV1) { | |
| 46 | + | for _, id := range m.pending { | |
| 47 | + | if h := m.handles[id]; h != nil && h.res.Alive() { | |
| 48 | + | m.s.emit(ports.WorkspaceActivate{ID: id}) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | m.pending = nil | |
| 52 | + | } | |
| 53 | + | func (m *workspaceManager) Stop(*ext.ExtWorkspaceManagerV1) { | |
| 54 | + | m.res.SendFinished() | |
| 55 | + | m.res.Destroy() | |
| 56 | + | } | |
| 57 | + | func (*workspaceManager) CreateWorkspace(*ext.ExtWorkspaceGroupHandleV1, string) {} | |
| 58 | + | func (*workspaceManager) Destroy(*ext.ExtWorkspaceGroupHandleV1) {} | |
| 59 | + | func (m *workspaceManager) Activate(r *ext.ExtWorkspaceHandleV1) { | |
| 60 | + | for id, h := range m.handles { | |
| 61 | + | if h.res.Resource == r.Resource { | |
| 62 | + | m.pending = append(m.pending, id) | |
| 63 | + | return | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // workspaceRequests adapts the workspace destroy request separately from group destroy. | |
| 69 | + | type workspaceRequests struct{ m *workspaceManager } | |
| 70 | + | ||
| 71 | + | func (*workspaceRequests) Destroy(*ext.ExtWorkspaceHandleV1) {} | |
| 72 | + | func (w *workspaceRequests) Activate(r *ext.ExtWorkspaceHandleV1) { w.m.Activate(r) } | |
| 73 | + | func (*workspaceRequests) Deactivate(*ext.ExtWorkspaceHandleV1) {} | |
| 74 | + | func (*workspaceRequests) Assign(*ext.ExtWorkspaceHandleV1, *ext.ExtWorkspaceGroupHandleV1) {} | |
| 75 | + | func (*workspaceRequests) Remove(*ext.ExtWorkspaceHandleV1) {} | |
| 76 | + | ||
| 77 | + | func (m *workspaceManager) outputResource(name string) *wayland.Output { | |
| 78 | + | o := m.s.outputByNameExact(name) | |
| 79 | + | if o == nil { | |
| 80 | + | return nil | |
| 81 | + | } | |
| 82 | + | for _, r := range o.resources { | |
| 83 | + | if r.Client() == m.res.Client() && r.Alive() { | |
| 84 | + | return r | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | return nil | |
| 88 | + | } | |
| 89 | + | func (m *workspaceManager) outputBound(name string) { | |
| 90 | + | g := m.groups[name] | |
| 91 | + | if g == nil || !g.res.Alive() { | |
| 92 | + | return | |
| 93 | + | } | |
| 94 | + | r := m.outputResource(name) | |
| 95 | + | if r == g.output { | |
| 96 | + | return | |
| 97 | + | } | |
| 98 | + | if g.output != nil && g.output.Alive() { | |
| 99 | + | g.res.SendOutputLeave(g.output) | |
| 100 | + | } | |
| 101 | + | g.output = r | |
| 102 | + | if r != nil { | |
| 103 | + | g.res.SendOutputEnter(r) | |
| 104 | + | } | |
| 105 | + | m.res.SendDone() | |
| 106 | + | } | |
| 107 | + | func (s *Server) forwardWorkspaces(ctx context.Context) { | |
| 108 | + | for { | |
| 109 | + | select { | |
| 110 | + | case <-ctx.Done(): | |
| 111 | + | return | |
| 112 | + | case <-s.display.Stopped(): | |
| 113 | + | return | |
| 114 | + | case snapshot, ok := <-s.channels.Workspaces: | |
| 115 | + | if !ok { | |
| 116 | + | return | |
| 117 | + | } | |
| 118 | + | if !s.display.Do(func() { s.updateWorkspaces(snapshot) }) { | |
| 119 | + | return | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | func (s *Server) updateWorkspaces(snapshot ports.Workspaces) { | |
| 125 | + | s.workspaceSnapshot = snapshot | |
| 126 | + | for _, m := range s.workspaceManagers { | |
| 127 | + | m.update(snapshot) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | func (m *workspaceManager) update(snapshot ports.Workspaces) { | |
| 131 | + | if !m.res.Alive() { | |
| 132 | + | return | |
| 133 | + | } | |
| 134 | + | wanted := map[string]bool{} | |
| 135 | + | ids := map[uint64]bool{} | |
| 136 | + | for _, out := range snapshot.Outputs { | |
| 137 | + | wanted[out.Name] = true | |
| 138 | + | for _, w := range out.Workspaces { | |
| 139 | + | ids[w.ID] = true | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | // Move existing workspaces before retiring their old output group. | |
| 143 | + | for id, h := range m.handles { | |
| 144 | + | if !ids[id] { | |
| 145 | + | if g := m.groups[h.group]; g != nil && g.res.Alive() && h.res.Alive() { | |
| 146 | + | g.res.SendWorkspaceLeave(h.res) | |
| 147 | + | } | |
| 148 | + | if h.res.Alive() { | |
| 149 | + | h.res.SendRemoved() | |
| 150 | + | } | |
| 151 | + | delete(m.handles, id) | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | for _, out := range snapshot.Outputs { | |
| 155 | + | g := m.groups[out.Name] | |
| 156 | + | if g != nil && !g.res.Alive() { | |
| 157 | + | delete(m.groups, out.Name) | |
| 158 | + | g = nil | |
| 159 | + | } | |
| 160 | + | if g == nil { | |
| 161 | + | r, err := ext.NewExtWorkspaceGroupHandleV1(m.res.Client(), 1, 0, m) | |
| 162 | + | if err != nil { | |
| 163 | + | continue | |
| 164 | + | } | |
| 165 | + | g = &workspaceGroup{res: r} | |
| 166 | + | m.groups[out.Name] = g | |
| 167 | + | m.res.SendWorkspaceGroup(r) | |
| 168 | + | r.SendCapabilities(0) | |
| 169 | + | } | |
| 170 | + | m.outputBound(out.Name) | |
| 171 | + | for _, w := range out.Workspaces { | |
| 172 | + | h := m.handles[w.ID] | |
| 173 | + | if h != nil && !h.res.Alive() { | |
| 174 | + | delete(m.handles, w.ID) | |
| 175 | + | h = nil | |
| 176 | + | } | |
| 177 | + | if h == nil { | |
| 178 | + | r, err := ext.NewExtWorkspaceHandleV1(m.res.Client(), 1, 0, &workspaceRequests{m}) | |
| 179 | + | if err != nil { | |
| 180 | + | continue | |
| 181 | + | } | |
| 182 | + | h = &workspaceHandle{res: r} | |
| 183 | + | m.handles[w.ID] = h | |
| 184 | + | m.res.SendWorkspace(r) | |
| 185 | + | r.SendCapabilities(uint32(ext.ExtWorkspaceHandleV1WorkspaceCapabilitiesActivate)) | |
| 186 | + | } | |
| 187 | + | if h.group != out.Name { | |
| 188 | + | if old := m.groups[h.group]; old != nil && old.res.Alive() && h.res.Alive() { | |
| 189 | + | old.res.SendWorkspaceLeave(h.res) | |
| 190 | + | } | |
| 191 | + | g.res.SendWorkspaceEnter(h.res) | |
| 192 | + | h.group = out.Name | |
| 193 | + | } | |
| 194 | + | if !reflect.DeepEqual(h.info, w) { | |
| 195 | + | if h.info.Name != w.Name { | |
| 196 | + | h.res.SendName(w.Name) | |
| 197 | + | } | |
| 198 | + | if h.info.Index != w.Index || h.info.ID == 0 { | |
| 199 | + | var b [4]byte | |
| 200 | + | binary.NativeEndian.PutUint32(b[:], uint32(w.Index)) | |
| 201 | + | h.res.SendCoordinates(b[:]) | |
| 202 | + | } | |
| 203 | + | if h.info.Active != w.Active || h.info.Hidden != w.Hidden || h.info.ID == 0 { | |
| 204 | + | var state uint32 | |
| 205 | + | if w.Active { | |
| 206 | + | state |= uint32(ext.ExtWorkspaceHandleV1StateActive) | |
| 207 | + | } | |
| 208 | + | if w.Hidden { | |
| 209 | + | state |= uint32(ext.ExtWorkspaceHandleV1StateHidden) | |
| 210 | + | } | |
| 211 | + | h.res.SendState(state) | |
| 212 | + | } | |
| 213 | + | if h.info.ID == 0 && w.Name != "" { | |
| 214 | + | h.res.SendId(strconv.FormatUint(w.ID, 10)) | |
| 215 | + | } | |
| 216 | + | h.info = w | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | for name, g := range m.groups { | |
| 221 | + | if !wanted[name] { | |
| 222 | + | if g.output != nil && g.output.Alive() && g.res.Alive() { | |
| 223 | + | g.res.SendOutputLeave(g.output) | |
| 224 | + | } | |
| 225 | + | if g.res.Alive() { | |
| 226 | + | g.res.SendRemoved() | |
| 227 | + | } | |
| 228 | + | delete(m.groups, name) | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | m.res.SendDone() | |
| 232 | + | } | |
| @@ -0,0 +1,232 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/binary" | |||
| 6 | + | "reflect" | |||
| 7 | + | "strconv" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 10 | + | ext "github.com/bnema/purego-libwayland/protocol/extworkspace" | |||
| 11 | + | "github.com/bnema/purego-libwayland/protocol/wayland" | |||
| 12 | + | "github.com/bnema/purego-libwayland/server" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type workspaceManager struct { | |||
| 16 | + | s *Server | |||
| 17 | + | res *ext.ExtWorkspaceManagerV1 | |||
| 18 | + | groups map[string]*workspaceGroup | |||
| 19 | + | handles map[uint64]*workspaceHandle | |||
| 20 | + | pending []uint64 | |||
| 21 | + | } | |||
| 22 | + | type workspaceGroup struct { | |||
| 23 | + | res *ext.ExtWorkspaceGroupHandleV1 | |||
| 24 | + | output *wayland.Output | |||
| 25 | + | } | |||
| 26 | + | type workspaceHandle struct { | |||
| 27 | + | res *ext.ExtWorkspaceHandleV1 | |||
| 28 | + | info ports.WorkspaceInfo | |||
| 29 | + | group string | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func registerWorkspaces(d *server.Display, s *Server) error { | |||
| 33 | + | return ext.NewExtWorkspaceManagerV1Global(d, 1, func(c server.Client, v, id uint32) { | |||
| 34 | + | m := &workspaceManager{s: s, groups: map[string]*workspaceGroup{}, handles: map[uint64]*workspaceHandle{}} | |||
| 35 | + | r, err := ext.NewExtWorkspaceManagerV1(c, int32(v), id, m) | |||
| 36 | + | if err != nil { | |||
| 37 | + | return | |||
| 38 | + | } | |||
| 39 | + | m.res = r | |||
| 40 | + | s.workspaceManagers = append(s.workspaceManagers, m) | |||
| 41 | + | r.OnDestroy = func() { s.workspaceManagers = removeItem(s.workspaceManagers, m) } | |||
| 42 | + | m.update(s.workspaceSnapshot) | |||
| 43 | + | }) | |||
| 44 | + | } | |||
| 45 | + | func (m *workspaceManager) Commit(*ext.ExtWorkspaceManagerV1) { | |||
| 46 | + | for _, id := range m.pending { | |||
| 47 | + | if h := m.handles[id]; h != nil && h.res.Alive() { | |||
| 48 | + | m.s.emit(ports.WorkspaceActivate{ID: id}) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | m.pending = nil | |||
| 52 | + | } | |||
| 53 | + | func (m *workspaceManager) Stop(*ext.ExtWorkspaceManagerV1) { | |||
| 54 | + | m.res.SendFinished() | |||
| 55 | + | m.res.Destroy() | |||
| 56 | + | } | |||
| 57 | + | func (*workspaceManager) CreateWorkspace(*ext.ExtWorkspaceGroupHandleV1, string) {} | |||
| 58 | + | func (*workspaceManager) Destroy(*ext.ExtWorkspaceGroupHandleV1) {} | |||
| 59 | + | func (m *workspaceManager) Activate(r *ext.ExtWorkspaceHandleV1) { | |||
| 60 | + | for id, h := range m.handles { | |||
| 61 | + | if h.res.Resource == r.Resource { | |||
| 62 | + | m.pending = append(m.pending, id) | |||
| 63 | + | return | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // workspaceRequests adapts the workspace destroy request separately from group destroy. | |||
| 69 | + | type workspaceRequests struct{ m *workspaceManager } | |||
| 70 | + | ||||
| 71 | + | func (*workspaceRequests) Destroy(*ext.ExtWorkspaceHandleV1) {} | |||
| 72 | + | func (w *workspaceRequests) Activate(r *ext.ExtWorkspaceHandleV1) { w.m.Activate(r) } | |||
| 73 | + | func (*workspaceRequests) Deactivate(*ext.ExtWorkspaceHandleV1) {} | |||
| 74 | + | func (*workspaceRequests) Assign(*ext.ExtWorkspaceHandleV1, *ext.ExtWorkspaceGroupHandleV1) {} | |||
| 75 | + | func (*workspaceRequests) Remove(*ext.ExtWorkspaceHandleV1) {} | |||
| 76 | + | ||||
| 77 | + | func (m *workspaceManager) outputResource(name string) *wayland.Output { | |||
| 78 | + | o := m.s.outputByNameExact(name) | |||
| 79 | + | if o == nil { | |||
| 80 | + | return nil | |||
| 81 | + | } | |||
| 82 | + | for _, r := range o.resources { | |||
| 83 | + | if r.Client() == m.res.Client() && r.Alive() { | |||
| 84 | + | return r | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | return nil | |||
| 88 | + | } | |||
| 89 | + | func (m *workspaceManager) outputBound(name string) { | |||
| 90 | + | g := m.groups[name] | |||
| 91 | + | if g == nil || !g.res.Alive() { | |||
| 92 | + | return | |||
| 93 | + | } | |||
| 94 | + | r := m.outputResource(name) | |||
| 95 | + | if r == g.output { | |||
| 96 | + | return | |||
| 97 | + | } | |||
| 98 | + | if g.output != nil && g.output.Alive() { | |||
| 99 | + | g.res.SendOutputLeave(g.output) | |||
| 100 | + | } | |||
| 101 | + | g.output = r | |||
| 102 | + | if r != nil { | |||
| 103 | + | g.res.SendOutputEnter(r) | |||
| 104 | + | } | |||
| 105 | + | m.res.SendDone() | |||
| 106 | + | } | |||
| 107 | + | func (s *Server) forwardWorkspaces(ctx context.Context) { | |||
| 108 | + | for { | |||
| 109 | + | select { | |||
| 110 | + | case <-ctx.Done(): | |||
| 111 | + | return | |||
| 112 | + | case <-s.display.Stopped(): | |||
| 113 | + | return | |||
| 114 | + | case snapshot, ok := <-s.channels.Workspaces: | |||
| 115 | + | if !ok { | |||
| 116 | + | return | |||
| 117 | + | } | |||
| 118 | + | if !s.display.Do(func() { s.updateWorkspaces(snapshot) }) { | |||
| 119 | + | return | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | func (s *Server) updateWorkspaces(snapshot ports.Workspaces) { | |||
| 125 | + | s.workspaceSnapshot = snapshot | |||
| 126 | + | for _, m := range s.workspaceManagers { | |||
| 127 | + | m.update(snapshot) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | func (m *workspaceManager) update(snapshot ports.Workspaces) { | |||
| 131 | + | if !m.res.Alive() { | |||
| 132 | + | return | |||
| 133 | + | } | |||
| 134 | + | wanted := map[string]bool{} | |||
| 135 | + | ids := map[uint64]bool{} | |||
| 136 | + | for _, out := range snapshot.Outputs { | |||
| 137 | + | wanted[out.Name] = true | |||
| 138 | + | for _, w := range out.Workspaces { | |||
| 139 | + | ids[w.ID] = true | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | // Move existing workspaces before retiring their old output group. | |||
| 143 | + | for id, h := range m.handles { | |||
| 144 | + | if !ids[id] { | |||
| 145 | + | if g := m.groups[h.group]; g != nil && g.res.Alive() && h.res.Alive() { | |||
| 146 | + | g.res.SendWorkspaceLeave(h.res) | |||
| 147 | + | } | |||
| 148 | + | if h.res.Alive() { | |||
| 149 | + | h.res.SendRemoved() | |||
| 150 | + | } | |||
| 151 | + | delete(m.handles, id) | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | for _, out := range snapshot.Outputs { | |||
| 155 | + | g := m.groups[out.Name] | |||
| 156 | + | if g != nil && !g.res.Alive() { | |||
| 157 | + | delete(m.groups, out.Name) | |||
| 158 | + | g = nil | |||
| 159 | + | } | |||
| 160 | + | if g == nil { | |||
| 161 | + | r, err := ext.NewExtWorkspaceGroupHandleV1(m.res.Client(), 1, 0, m) | |||
| 162 | + | if err != nil { | |||
| 163 | + | continue | |||
| 164 | + | } | |||
| 165 | + | g = &workspaceGroup{res: r} | |||
| 166 | + | m.groups[out.Name] = g | |||
| 167 | + | m.res.SendWorkspaceGroup(r) | |||
| 168 | + | r.SendCapabilities(0) | |||
| 169 | + | } | |||
| 170 | + | m.outputBound(out.Name) | |||
| 171 | + | for _, w := range out.Workspaces { | |||
| 172 | + | h := m.handles[w.ID] | |||
| 173 | + | if h != nil && !h.res.Alive() { | |||
| 174 | + | delete(m.handles, w.ID) | |||
| 175 | + | h = nil | |||
| 176 | + | } | |||
| 177 | + | if h == nil { | |||
| 178 | + | r, err := ext.NewExtWorkspaceHandleV1(m.res.Client(), 1, 0, &workspaceRequests{m}) | |||
| 179 | + | if err != nil { | |||
| 180 | + | continue | |||
| 181 | + | } | |||
| 182 | + | h = &workspaceHandle{res: r} | |||
| 183 | + | m.handles[w.ID] = h | |||
| 184 | + | m.res.SendWorkspace(r) | |||
| 185 | + | r.SendCapabilities(uint32(ext.ExtWorkspaceHandleV1WorkspaceCapabilitiesActivate)) | |||
| 186 | + | } | |||
| 187 | + | if h.group != out.Name { | |||
| 188 | + | if old := m.groups[h.group]; old != nil && old.res.Alive() && h.res.Alive() { | |||
| 189 | + | old.res.SendWorkspaceLeave(h.res) | |||
| 190 | + | } | |||
| 191 | + | g.res.SendWorkspaceEnter(h.res) | |||
| 192 | + | h.group = out.Name | |||
| 193 | + | } | |||
| 194 | + | if !reflect.DeepEqual(h.info, w) { | |||
| 195 | + | if h.info.Name != w.Name { | |||
| 196 | + | h.res.SendName(w.Name) | |||
| 197 | + | } | |||
| 198 | + | if h.info.Index != w.Index || h.info.ID == 0 { | |||
| 199 | + | var b [4]byte | |||
| 200 | + | binary.NativeEndian.PutUint32(b[:], uint32(w.Index)) | |||
| 201 | + | h.res.SendCoordinates(b[:]) | |||
| 202 | + | } | |||
| 203 | + | if h.info.Active != w.Active || h.info.Hidden != w.Hidden || h.info.ID == 0 { | |||
| 204 | + | var state uint32 | |||
| 205 | + | if w.Active { | |||
| 206 | + | state |= uint32(ext.ExtWorkspaceHandleV1StateActive) | |||
| 207 | + | } | |||
| 208 | + | if w.Hidden { | |||
| 209 | + | state |= uint32(ext.ExtWorkspaceHandleV1StateHidden) | |||
| 210 | + | } | |||
| 211 | + | h.res.SendState(state) | |||
| 212 | + | } | |||
| 213 | + | if h.info.ID == 0 && w.Name != "" { | |||
| 214 | + | h.res.SendId(strconv.FormatUint(w.ID, 10)) | |||
| 215 | + | } | |||
| 216 | + | h.info = w | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | for name, g := range m.groups { | |||
| 221 | + | if !wanted[name] { | |||
| 222 | + | if g.output != nil && g.output.Alive() && g.res.Alive() { | |||
| 223 | + | g.res.SendOutputLeave(g.output) | |||
| 224 | + | } | |||
| 225 | + | if g.res.Alive() { | |||
| 226 | + | g.res.SendRemoved() | |||
| 227 | + | } | |||
| 228 | + | delete(m.groups, name) | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | m.res.SendDone() | |||
| 232 | + | } | |||
A internal/adapters/wayland/workspaces_test.go +165 -0
| @@ -0,0 +1,165 @@ | |||
| 1 | + | package wayland | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/neferwl/internal/logging" | |
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | ext "github.com/bnema/purego-libwayland/protocol/extworkspace" | |
| 11 | + | "github.com/bnema/wlturbo" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type workspaceEvents struct { | |
| 15 | + | wlturbo.BaseProxy | |
| 16 | + | client *wlturbo.Display | |
| 17 | + | events chan [2]uint32 | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (p *workspaceEvents) Dispatch(e *wlturbo.Event) { | |
| 21 | + | v := [2]uint32{uint32(e.Opcode), 0} | |
| 22 | + | if e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup) || e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspace) { | |
| 23 | + | v[1] = e.Uint32() | |
| 24 | + | child := &workspaceChildEvents{events: p.events, group: e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup)} | |
| 25 | + | child.SetID(v[1]) | |
| 26 | + | p.client.Context().Register(child) | |
| 27 | + | } | |
| 28 | + | p.events <- v | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | type workspaceChildEvents struct { | |
| 32 | + | wlturbo.BaseProxy | |
| 33 | + | events chan [2]uint32 | |
| 34 | + | group bool | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (p *workspaceChildEvents) Dispatch(e *wlturbo.Event) { | |
| 38 | + | // Encode the interface in the upper byte to distinguish manager, group and handle. | |
| 39 | + | kind := uint32(2) | |
| 40 | + | if p.group { | |
| 41 | + | kind = 1 | |
| 42 | + | } | |
| 43 | + | v := [2]uint32{kind<<8 | uint32(e.Opcode), 0} | |
| 44 | + | switch { | |
| 45 | + | case p.group && (e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventOutputEnter) || e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventOutputLeave)): | |
| 46 | + | v[1] = e.Uint32() | |
| 47 | + | case p.group && (e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter) || e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventWorkspaceLeave)): | |
| 48 | + | v[1] = e.Uint32() | |
| 49 | + | case !p.group && (e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventState) || e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventCapabilities)): | |
| 50 | + | v[1] = e.Uint32() | |
| 51 | + | } | |
| 52 | + | p.events <- v | |
| 53 | + | } | |
| 54 | + | func TestWorkspaceProtocol(t *testing.T) { | |
| 55 | + | dir := t.TempDir() | |
| 56 | + | events := make(chan ports.ClientEvent, 16) | |
| 57 | + | snapshots := make(chan ports.Workspaces, 1) | |
| 58 | + | s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Workspaces: snapshots}, logging.For(context.Background(), "wayland")) | |
| 59 | + | if err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 63 | + | done := make(chan error, 1) | |
| 64 | + | go func() { done <- s.Run(ctx) }() | |
| 65 | + | t.Cleanup(func() { | |
| 66 | + | cancel() | |
| 67 | + | if err := <-done; err != nil { | |
| 68 | + | t.Error(err) | |
| 69 | + | } | |
| 70 | + | }) | |
| 71 | + | c := protocolClient(t, s, dir) | |
| 72 | + | snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Active: true}}}}} | |
| 73 | + | if !s.display.Do(func() {}) { | |
| 74 | + | t.Fatal("display stopped") | |
| 75 | + | } | |
| 76 | + | managerID := bindProtocol(t, c, "ext_workspace_manager_v1") | |
| 77 | + | p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128)} | |
| 78 | + | p.SetID(managerID) | |
| 79 | + | c.Context().Register(p) | |
| 80 | + | var handle uint32 | |
| 81 | + | for i := 0; i < 8 && handle == 0; i++ { | |
| 82 | + | if err := c.Roundtrip(); err != nil { | |
| 83 | + | t.Fatal(err) | |
| 84 | + | } | |
| 85 | + | for len(p.events) > 0 { | |
| 86 | + | v := <-p.events | |
| 87 | + | if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace { | |
| 88 | + | handle = v[1] | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | if handle == 0 { | |
| 93 | + | t.Fatal("workspace not announced") | |
| 94 | + | } | |
| 95 | + | out := bindProtocol(t, c, "wl_output") | |
| 96 | + | registerProtocol(t, c, out) | |
| 97 | + | entered := false | |
| 98 | + | for i := 0; i < 8 && !entered; i++ { | |
| 99 | + | if err := c.Roundtrip(); err != nil { | |
| 100 | + | t.Fatal(err) | |
| 101 | + | } | |
| 102 | + | for len(p.events) > 0 { | |
| 103 | + | v := <-p.events | |
| 104 | + | if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, out} { | |
| 105 | + | entered = true | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | if !entered { | |
| 110 | + | t.Fatal("late wl_output did not enter group") | |
| 111 | + | } | |
| 112 | + | requestProtocol(t, c, handle, ext.ExtWorkspaceHandleV1RequestActivate) | |
| 113 | + | if err := c.Roundtrip(); err != nil { | |
| 114 | + | t.Fatal(err) | |
| 115 | + | } | |
| 116 | + | select { | |
| 117 | + | case v := <-events: | |
| 118 | + | t.Fatalf("activation before commit: %v", v) | |
| 119 | + | default: | |
| 120 | + | } | |
| 121 | + | requestProtocol(t, c, managerID, ext.ExtWorkspaceManagerV1RequestCommit) | |
| 122 | + | if err := c.Roundtrip(); err != nil { | |
| 123 | + | t.Fatal(err) | |
| 124 | + | } | |
| 125 | + | select { | |
| 126 | + | case v := <-events: | |
| 127 | + | if v != (ports.WorkspaceActivate{ID: 42}) { | |
| 128 | + | t.Fatal(v) | |
| 129 | + | } | |
| 130 | + | case <-time.After(time.Second): | |
| 131 | + | t.Fatal("no activation") | |
| 132 | + | } | |
| 133 | + | snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Hidden: true}}}}} | |
| 134 | + | found := false | |
| 135 | + | for i := 0; i < 8 && !found; i++ { | |
| 136 | + | if err := c.Roundtrip(); err != nil { | |
| 137 | + | t.Fatal(err) | |
| 138 | + | } | |
| 139 | + | for len(p.events) > 0 { | |
| 140 | + | v := <-p.events | |
| 141 | + | if v == [2]uint32{2<<8 | ext.ExtWorkspaceHandleV1EventState, uint32(ext.ExtWorkspaceHandleV1StateHidden)} { | |
| 142 | + | found = true | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | if !found { | |
| 147 | + | t.Fatal("no state change") | |
| 148 | + | } | |
| 149 | + | snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{}} | |
| 150 | + | found = false | |
| 151 | + | for i := 0; i < 8 && !found; i++ { | |
| 152 | + | if err := c.Roundtrip(); err != nil { | |
| 153 | + | t.Fatal(err) | |
| 154 | + | } | |
| 155 | + | for len(p.events) > 0 { | |
| 156 | + | v := <-p.events | |
| 157 | + | if v[0] == 1<<8|ext.ExtWorkspaceGroupHandleV1EventRemoved { | |
| 158 | + | found = true | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | if !found { | |
| 163 | + | t.Fatal("group not removed") | |
| 164 | + | } | |
| 165 | + | } | |
| @@ -0,0 +1,165 @@ | |||||
| 1 | + | package wayland | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/neferwl/internal/logging" | |||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 10 | + | ext "github.com/bnema/purego-libwayland/protocol/extworkspace" | |||
| 11 | + | "github.com/bnema/wlturbo" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | type workspaceEvents struct { | |||
| 15 | + | wlturbo.BaseProxy | |||
| 16 | + | client *wlturbo.Display | |||
| 17 | + | events chan [2]uint32 | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func (p *workspaceEvents) Dispatch(e *wlturbo.Event) { | |||
| 21 | + | v := [2]uint32{uint32(e.Opcode), 0} | |||
| 22 | + | if e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup) || e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspace) { | |||
| 23 | + | v[1] = e.Uint32() | |||
| 24 | + | child := &workspaceChildEvents{events: p.events, group: e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup)} | |||
| 25 | + | child.SetID(v[1]) | |||
| 26 | + | p.client.Context().Register(child) | |||
| 27 | + | } | |||
| 28 | + | p.events <- v | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | type workspaceChildEvents struct { | |||
| 32 | + | wlturbo.BaseProxy | |||
| 33 | + | events chan [2]uint32 | |||
| 34 | + | group bool | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (p *workspaceChildEvents) Dispatch(e *wlturbo.Event) { | |||
| 38 | + | // Encode the interface in the upper byte to distinguish manager, group and handle. | |||
| 39 | + | kind := uint32(2) | |||
| 40 | + | if p.group { | |||
| 41 | + | kind = 1 | |||
| 42 | + | } | |||
| 43 | + | v := [2]uint32{kind<<8 | uint32(e.Opcode), 0} | |||
| 44 | + | switch { | |||
| 45 | + | case p.group && (e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventOutputEnter) || e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventOutputLeave)): | |||
| 46 | + | v[1] = e.Uint32() | |||
| 47 | + | case p.group && (e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter) || e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventWorkspaceLeave)): | |||
| 48 | + | v[1] = e.Uint32() | |||
| 49 | + | case !p.group && (e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventState) || e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventCapabilities)): | |||
| 50 | + | v[1] = e.Uint32() | |||
| 51 | + | } | |||
| 52 | + | p.events <- v | |||
| 53 | + | } | |||
| 54 | + | func TestWorkspaceProtocol(t *testing.T) { | |||
| 55 | + | dir := t.TempDir() | |||
| 56 | + | events := make(chan ports.ClientEvent, 16) | |||
| 57 | + | snapshots := make(chan ports.Workspaces, 1) | |||
| 58 | + | s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, Workspaces: snapshots}, logging.For(context.Background(), "wayland")) | |||
| 59 | + | if err != nil { | |||
| 60 | + | t.Fatal(err) | |||
| 61 | + | } | |||
| 62 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 63 | + | done := make(chan error, 1) | |||
| 64 | + | go func() { done <- s.Run(ctx) }() | |||
| 65 | + | t.Cleanup(func() { | |||
| 66 | + | cancel() | |||
| 67 | + | if err := <-done; err != nil { | |||
| 68 | + | t.Error(err) | |||
| 69 | + | } | |||
| 70 | + | }) | |||
| 71 | + | c := protocolClient(t, s, dir) | |||
| 72 | + | snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Active: true}}}}} | |||
| 73 | + | if !s.display.Do(func() {}) { | |||
| 74 | + | t.Fatal("display stopped") | |||
| 75 | + | } | |||
| 76 | + | managerID := bindProtocol(t, c, "ext_workspace_manager_v1") | |||
| 77 | + | p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128)} | |||
| 78 | + | p.SetID(managerID) | |||
| 79 | + | c.Context().Register(p) | |||
| 80 | + | var handle uint32 | |||
| 81 | + | for i := 0; i < 8 && handle == 0; i++ { | |||
| 82 | + | if err := c.Roundtrip(); err != nil { | |||
| 83 | + | t.Fatal(err) | |||
| 84 | + | } | |||
| 85 | + | for len(p.events) > 0 { | |||
| 86 | + | v := <-p.events | |||
| 87 | + | if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace { | |||
| 88 | + | handle = v[1] | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | if handle == 0 { | |||
| 93 | + | t.Fatal("workspace not announced") | |||
| 94 | + | } | |||
| 95 | + | out := bindProtocol(t, c, "wl_output") | |||
| 96 | + | registerProtocol(t, c, out) | |||
| 97 | + | entered := false | |||
| 98 | + | for i := 0; i < 8 && !entered; i++ { | |||
| 99 | + | if err := c.Roundtrip(); err != nil { | |||
| 100 | + | t.Fatal(err) | |||
| 101 | + | } | |||
| 102 | + | for len(p.events) > 0 { | |||
| 103 | + | v := <-p.events | |||
| 104 | + | if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, out} { | |||
| 105 | + | entered = true | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | if !entered { | |||
| 110 | + | t.Fatal("late wl_output did not enter group") | |||
| 111 | + | } | |||
| 112 | + | requestProtocol(t, c, handle, ext.ExtWorkspaceHandleV1RequestActivate) | |||
| 113 | + | if err := c.Roundtrip(); err != nil { | |||
| 114 | + | t.Fatal(err) | |||
| 115 | + | } | |||
| 116 | + | select { | |||
| 117 | + | case v := <-events: | |||
| 118 | + | t.Fatalf("activation before commit: %v", v) | |||
| 119 | + | default: | |||
| 120 | + | } | |||
| 121 | + | requestProtocol(t, c, managerID, ext.ExtWorkspaceManagerV1RequestCommit) | |||
| 122 | + | if err := c.Roundtrip(); err != nil { | |||
| 123 | + | t.Fatal(err) | |||
| 124 | + | } | |||
| 125 | + | select { | |||
| 126 | + | case v := <-events: | |||
| 127 | + | if v != (ports.WorkspaceActivate{ID: 42}) { | |||
| 128 | + | t.Fatal(v) | |||
| 129 | + | } | |||
| 130 | + | case <-time.After(time.Second): | |||
| 131 | + | t.Fatal("no activation") | |||
| 132 | + | } | |||
| 133 | + | snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Hidden: true}}}}} | |||
| 134 | + | found := false | |||
| 135 | + | for i := 0; i < 8 && !found; i++ { | |||
| 136 | + | if err := c.Roundtrip(); err != nil { | |||
| 137 | + | t.Fatal(err) | |||
| 138 | + | } | |||
| 139 | + | for len(p.events) > 0 { | |||
| 140 | + | v := <-p.events | |||
| 141 | + | if v == [2]uint32{2<<8 | ext.ExtWorkspaceHandleV1EventState, uint32(ext.ExtWorkspaceHandleV1StateHidden)} { | |||
| 142 | + | found = true | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | if !found { | |||
| 147 | + | t.Fatal("no state change") | |||
| 148 | + | } | |||
| 149 | + | snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{}} | |||
| 150 | + | found = false | |||
| 151 | + | for i := 0; i < 8 && !found; i++ { | |||
| 152 | + | if err := c.Roundtrip(); err != nil { | |||
| 153 | + | t.Fatal(err) | |||
| 154 | + | } | |||
| 155 | + | for len(p.events) > 0 { | |||
| 156 | + | v := <-p.events | |||
| 157 | + | if v[0] == 1<<8|ext.ExtWorkspaceGroupHandleV1EventRemoved { | |||
| 158 | + | found = true | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | if !found { | |||
| 163 | + | t.Fatal("group not removed") | |||
| 164 | + | } | |||
| 165 | + | } | |||
M internal/app/app.go +3 -2
| @@ -85,6 +85,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 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 +19 -3
| @@ -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, when set, keeps a window on every workspace on screen: an | |
| 41 | 42 | // empty one gets the configured terminal (the terminal is the desktop). | |
| 42 | 43 | Terminal bool | |
| @@ -77,8 +78,9 @@ type Core struct { | |||
| 77 | 78 | // token, until their window maps. | |
| 78 | 79 | terms map[string]*termSpawn | |
| 79 | 80 | // clients holds the app ID and PID of mapped windows, for State. | |
| 80 | - | clients map[WindowID]ports.WindowMapped | |
| 81 | - | sentState ports.State | |
| 81 | + | clients map[WindowID]ports.WindowMapped | |
| 82 | + | sentState ports.State | |
| 83 | + | sentWorkspaces ports.Workspaces | |
| 82 | 84 | // popups are the placed xdg_popups; popupOrder stacks them, oldest first. | |
| 83 | 85 | popups map[WindowID]*popupState | |
| 84 | 86 | popupOrder []WindowID | |
| @@ -471,6 +473,7 @@ func (c *Core) publish(ctx context.Context) error { | |||
| 471 | 473 | c.resolveConstraint() | |
| 472 | 474 | latest(c.ch.Scenes, scenes) | |
| 473 | 475 | c.publishState() | |
| 476 | + | c.publishWorkspaces() | |
| 474 | 477 | return nil | |
| 475 | 478 | } | |
| 476 | 479 | ||
| @@ -623,6 +626,7 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 623 | 626 | return nil | |
| 624 | 627 | } | |
| 625 | 628 | c.publishState() | |
| 629 | + | c.publishWorkspaces() | |
| 626 | 630 | for { | |
| 627 | 631 | select { | |
| 628 | 632 | case <-ctx.Done(): | |
| @@ -714,6 +718,18 @@ func (c *Core) Run(ctx context.Context) error { | |||
| 714 | 718 | if s, _ := c.screenOf(v.ID); s != nil { | |
| 715 | 719 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | |
| 716 | 720 | } | |
| 721 | + | case ports.WorkspaceActivate: | |
| 722 | + | for _, sc := range c.screens { | |
| 723 | + | if sc.name() == "" { | |
| 724 | + | continue | |
| 725 | + | } | |
| 726 | + | for _, w := range sc.mon.all() { | |
| 727 | + | if w.ID == v.ID { | |
| 728 | + | sc.mon.show(w) | |
| 729 | + | break | |
| 730 | + | } | |
| 731 | + | } | |
| 732 | + | } | |
| 717 | 733 | case ports.WindowActivate: | |
| 718 | 734 | if c.activate(ctx, v.ID) != nil { | |
| 719 | 735 | 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, when set, keeps a window on every workspace on screen: an | 41 | // Terminal, when set, keeps a window on every workspace on screen: an | ||
| 41 | // empty one gets the configured terminal (the terminal is the desktop). | 42 | // empty one gets the configured terminal (the terminal is the desktop). | ||
| 42 | Terminal bool | 43 | Terminal bool | ||
| @@ -77,8 +78,9 @@ type Core struct { | |||||
| 77 | // token, until their window maps. | 78 | // token, until their window maps. | ||
| 78 | terms map[string]*termSpawn | 79 | terms map[string]*termSpawn | ||
| 79 | // clients holds the app ID and PID of mapped windows, for State. | 80 | // clients holds the app ID and PID of mapped windows, for State. | ||
| 80 | - | clients map[WindowID]ports.WindowMapped | 81 | + | clients map[WindowID]ports.WindowMapped |
| 81 | - | sentState ports.State | 82 | + | sentState ports.State |
| 83 | + | sentWorkspaces ports.Workspaces | |||
| 82 | // popups are the placed xdg_popups; popupOrder stacks them, oldest first. | 84 | // popups are the placed xdg_popups; popupOrder stacks them, oldest first. | ||
| 83 | popups map[WindowID]*popupState | 85 | popups map[WindowID]*popupState | ||
| 84 | popupOrder []WindowID | 86 | popupOrder []WindowID | ||
| @@ -471,6 +473,7 @@ func (c *Core) publish(ctx context.Context) error { | |||||
| 471 | c.resolveConstraint() | 473 | c.resolveConstraint() | ||
| 472 | latest(c.ch.Scenes, scenes) | 474 | latest(c.ch.Scenes, scenes) | ||
| 473 | c.publishState() | 475 | c.publishState() | ||
| 476 | + | c.publishWorkspaces() | |||
| 474 | return nil | 477 | return nil | ||
| 475 | } | 478 | } | ||
| 476 | 479 | ||||
| @@ -623,6 +626,7 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 623 | return nil | 626 | return nil | ||
| 624 | } | 627 | } | ||
| 625 | c.publishState() | 628 | c.publishState() | ||
| 629 | + | c.publishWorkspaces() | |||
| 626 | for { | 630 | for { | ||
| 627 | select { | 631 | select { | ||
| 628 | case <-ctx.Done(): | 632 | case <-ctx.Done(): | ||
| @@ -714,6 +718,18 @@ func (c *Core) Run(ctx context.Context) error { | |||||
| 714 | if s, _ := c.screenOf(v.ID); s != nil { | 718 | if s, _ := c.screenOf(v.ID); s != nil { | ||
| 715 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | 719 | s.mon.SetFullscreen(v.ID, v.Fullscreen) | ||
| 716 | } | 720 | } | ||
| 721 | + | case ports.WorkspaceActivate: | |||
| 722 | + | for _, sc := range c.screens { | |||
| 723 | + | if sc.name() == "" { | |||
| 724 | + | continue | |||
| 725 | + | } | |||
| 726 | + | for _, w := range sc.mon.all() { | |||
| 727 | + | if w.ID == v.ID { | |||
| 728 | + | sc.mon.show(w) | |||
| 729 | + | break | |||
| 730 | + | } | |||
| 731 | + | } | |||
| 732 | + | } | |||
| 717 | case ports.WindowActivate: | 733 | case ports.WindowActivate: | ||
| 718 | if c.activate(ctx, v.ID) != nil { | 734 | if c.activate(ctx, v.ID) != nil { | ||
| 719 | return nil | 735 | 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 +7 -1
| @@ -1,6 +1,11 @@ | |||
| 1 | 1 | package core | |
| 2 | 2 | ||
| 3 | - | import "slices" | |
| 3 | + | import ( | |
| 4 | + | "slices" | |
| 5 | + | "sync/atomic" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | var nextWorkspaceID atomic.Uint64 | |
| 4 | 9 | ||
| 5 | 10 | // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers | |
| 6 | 11 | // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits | |
| @@ -120,6 +125,7 @@ func (m *Monitor) newWorkspace() *Workspace { | |||
| 120 | 125 | w := m.template | |
| 121 | 126 | w.presets = append([]Width(nil), m.template.presets...) | |
| 122 | 127 | w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{} | |
| 128 | + | w.ID = nextWorkspaceID.Add(1) | |
| 123 | 129 | return &w | |
| 124 | 130 | } | |
| 125 | 131 | ||
| @@ -1,6 +1,11 @@ | |||||
| 1 | package core | 1 | package core | ||
| 2 | 2 | ||||
| 3 | - | import "slices" | 3 | + | import ( |
| 4 | + | "slices" | |||
| 5 | + | "sync/atomic" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | var nextWorkspaceID atomic.Uint64 | |||
| 4 | 9 | ||||
| 5 | // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers | 10 | // Monitor owns an ordered list of workspaces for one output (ADR 011). Numbers | ||
| 6 | // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits | 11 | // are positions: Cmd+N targets Workspaces[N-1]. An empty workspace always sits | ||
| @@ -120,6 +125,7 @@ func (m *Monitor) newWorkspace() *Workspace { | |||||
| 120 | w := m.template | 125 | w := m.template | ||
| 121 | w.presets = append([]Width(nil), m.template.presets...) | 126 | w.presets = append([]Width(nil), m.template.presets...) | ||
| 122 | w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{} | 127 | w.Columns, w.Floats, w.floatFocus, w.home, w.origin, w.back = nil, nil, false, "", nil, origPlace{} | ||
| 128 | + | w.ID = nextWorkspaceID.Add(1) | |||
| 123 | return &w | 129 | return &w | ||
| 124 | } | 130 | } | ||
| 125 | 131 | ||||
A internal/core/workspaces.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |
| 5 | + | "reflect" | |
| 6 | + | "strconv" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // publishWorkspaces describes the numbered vertical stack and configured hidden | |
| 10 | + | // workspaces. Empty numbered spares are hidden unless currently shown; unlike | |
| 11 | + | // the script State snapshot, hidden named workspaces remain addressable. | |
| 12 | + | func (c *Core) publishWorkspaces() { | |
| 13 | + | if c.ch.Workspaces == nil { | |
| 14 | + | return | |
| 15 | + | } | |
| 16 | + | snapshot := ports.Workspaces{Outputs: []ports.WorkspaceOutput{}} | |
| 17 | + | for _, sc := range c.screens { | |
| 18 | + | if sc.name() == "" { | |
| 19 | + | continue | |
| 20 | + | } | |
| 21 | + | m := sc.mon | |
| 22 | + | out := ports.WorkspaceOutput{Name: sc.name(), Workspaces: []ports.WorkspaceInfo{}} | |
| 23 | + | for i, w := range m.all() { | |
| 24 | + | name := w.Name | |
| 25 | + | if name == "" { | |
| 26 | + | name = strconv.Itoa(i + 1) | |
| 27 | + | } | |
| 28 | + | out.Workspaces = append(out.Workspaces, ports.WorkspaceInfo{ID: w.ID, Name: name, Index: i, Active: m.Current() == w, Hidden: w.empty() && m.Current() != w}) | |
| 29 | + | } | |
| 30 | + | snapshot.Outputs = append(snapshot.Outputs, out) | |
| 31 | + | } | |
| 32 | + | if reflect.DeepEqual(snapshot, c.sentWorkspaces) { | |
| 33 | + | return | |
| 34 | + | } | |
| 35 | + | c.sentWorkspaces = snapshot | |
| 36 | + | latest(c.ch.Workspaces, snapshot) | |
| 37 | + | } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 5 | + | "reflect" | |||
| 6 | + | "strconv" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // publishWorkspaces describes the numbered vertical stack and configured hidden | |||
| 10 | + | // workspaces. Empty numbered spares are hidden unless currently shown; unlike | |||
| 11 | + | // the script State snapshot, hidden named workspaces remain addressable. | |||
| 12 | + | func (c *Core) publishWorkspaces() { | |||
| 13 | + | if c.ch.Workspaces == nil { | |||
| 14 | + | return | |||
| 15 | + | } | |||
| 16 | + | snapshot := ports.Workspaces{Outputs: []ports.WorkspaceOutput{}} | |||
| 17 | + | for _, sc := range c.screens { | |||
| 18 | + | if sc.name() == "" { | |||
| 19 | + | continue | |||
| 20 | + | } | |||
| 21 | + | m := sc.mon | |||
| 22 | + | out := ports.WorkspaceOutput{Name: sc.name(), Workspaces: []ports.WorkspaceInfo{}} | |||
| 23 | + | for i, w := range m.all() { | |||
| 24 | + | name := w.Name | |||
| 25 | + | if name == "" { | |||
| 26 | + | name = strconv.Itoa(i + 1) | |||
| 27 | + | } | |||
| 28 | + | out.Workspaces = append(out.Workspaces, ports.WorkspaceInfo{ID: w.ID, Name: name, Index: i, Active: m.Current() == w, Hidden: w.empty() && m.Current() != w}) | |||
| 29 | + | } | |||
| 30 | + | snapshot.Outputs = append(snapshot.Outputs, out) | |||
| 31 | + | } | |||
| 32 | + | if reflect.DeepEqual(snapshot, c.sentWorkspaces) { | |||
| 33 | + | return | |||
| 34 | + | } | |||
| 35 | + | c.sentWorkspaces = snapshot | |||
| 36 | + | latest(c.ch.Workspaces, snapshot) | |||
| 37 | + | } | |||
A internal/core/workspaces_test.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | package core_test | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/neferwl/internal/adapters/config" | |
| 8 | + | "github.com/bnema/neferwl/internal/core" | |
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestWorkspaceSnapshotsAndActivation(t *testing.T) { | |
| 13 | + | cfg := config.Defaults() | |
| 14 | + | client := make(chan ports.ClientEvent, 16) | |
| 15 | + | input := make(chan ports.InputEvent, 8) | |
| 16 | + | output := make(chan ports.OutputEvent, 8) | |
| 17 | + | commands := make(chan ports.ClientCommand, 32) | |
| 18 | + | snapshots := make(chan ports.Workspaces, 1) | |
| 19 | + | scenes := make(chan []ports.Scene, 1) | |
| 20 | + | spawn := make(chan ports.SpawnRequest, 8) | |
| 21 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Workspaces: snapshots, Scenes: scenes, Spawn: spawn}) | |
| 22 | + | if err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 26 | + | done := make(chan error, 1) | |
| 27 | + | go func() { done <- c.Run(ctx) }() | |
| 28 | + | t.Cleanup(func() { | |
| 29 | + | cancel() | |
| 30 | + | if err := <-done; err != nil { | |
| 31 | + | t.Error(err) | |
| 32 | + | } | |
| 33 | + | }) | |
| 34 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 100, Height: 80}} | |
| 35 | + | first := receive(t, snapshots) | |
| 36 | + | for len(first.Outputs) == 0 { | |
| 37 | + | first = receive(t, snapshots) | |
| 38 | + | } | |
| 39 | + | if len(first.Outputs) != 1 || len(first.Outputs[0].Workspaces) != 1 || !first.Outputs[0].Workspaces[0].Active { | |
| 40 | + | t.Fatal(first) | |
| 41 | + | } | |
| 42 | + | id := first.Outputs[0].Workspaces[0].ID | |
| 43 | + | client <- ports.WindowMapped{ID: 1} | |
| 44 | + | added := receive(t, snapshots) | |
| 45 | + | if len(added.Outputs[0].Workspaces) < 2 || added.Outputs[0].Workspaces[0].ID != id || !added.Outputs[0].Workspaces[1].Hidden { | |
| 46 | + | t.Fatal(added) | |
| 47 | + | } | |
| 48 | + | spare := added.Outputs[0].Workspaces[1].ID | |
| 49 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "B", Width: 100, Height: 80}} | |
| 50 | + | two := receive(t, snapshots) | |
| 51 | + | if len(two.Outputs) != 2 || two.Outputs[0].Workspaces[0].ID != id { | |
| 52 | + | t.Fatal(two) | |
| 53 | + | } | |
| 54 | + | client <- ports.WorkspaceActivate{ID: spare} | |
| 55 | + | active := receive(t, snapshots) | |
| 56 | + | if !active.Outputs[0].Workspaces[1].Active || active.Outputs[1].Workspaces[0].Active != true { | |
| 57 | + | t.Fatal(active) | |
| 58 | + | } | |
| 59 | + | client <- ports.WorkspaceActivate{ID: 999999999} | |
| 60 | + | scene(t, scenes) | |
| 61 | + | select { | |
| 62 | + | case v := <-snapshots: | |
| 63 | + | t.Fatalf("unknown ID changed snapshot: %v", v) | |
| 64 | + | default: | |
| 65 | + | } | |
| 66 | + | // Move the populated workspace right; its ID follows it. | |
| 67 | + | client <- ports.WorkspaceActivate{ID: id} | |
| 68 | + | receive(t, snapshots) | |
| 69 | + | input <- ports.KeyEvent{Keysym: "Right", Mods: ports.ModSuper | ports.ModCtrl | ports.ModShift, Pressed: true} | |
| 70 | + | move := receive(t, snapshots) | |
| 71 | + | found := false | |
| 72 | + | for _, w := range move.Outputs[1].Workspaces { | |
| 73 | + | if w.ID == id { | |
| 74 | + | found = true | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | if !found { | |
| 78 | + | t.Fatalf("workspace ID lost on move: %+v", move) | |
| 79 | + | } | |
| 80 | + | output <- ports.OutputRemoved{Name: "B"} | |
| 81 | + | moved := receive(t, snapshots) | |
| 82 | + | if len(moved.Outputs) != 1 || moved.Outputs[0].Name != "A" { | |
| 83 | + | t.Fatal(moved) | |
| 84 | + | } | |
| 85 | + | } | |
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | package core_test | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/neferwl/internal/adapters/config" | |||
| 8 | + | "github.com/bnema/neferwl/internal/core" | |||
| 9 | + | "github.com/bnema/neferwl/internal/ports" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestWorkspaceSnapshotsAndActivation(t *testing.T) { | |||
| 13 | + | cfg := config.Defaults() | |||
| 14 | + | client := make(chan ports.ClientEvent, 16) | |||
| 15 | + | input := make(chan ports.InputEvent, 8) | |||
| 16 | + | output := make(chan ports.OutputEvent, 8) | |||
| 17 | + | commands := make(chan ports.ClientCommand, 32) | |||
| 18 | + | snapshots := make(chan ports.Workspaces, 1) | |||
| 19 | + | scenes := make(chan []ports.Scene, 1) | |||
| 20 | + | spawn := make(chan ports.SpawnRequest, 8) | |||
| 21 | + | c, err := core.New(cfg, core.Channels{Client: client, Input: input, Output: output, Commands: commands, Workspaces: snapshots, Scenes: scenes, Spawn: spawn}) | |||
| 22 | + | if err != nil { | |||
| 23 | + | t.Fatal(err) | |||
| 24 | + | } | |||
| 25 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 26 | + | done := make(chan error, 1) | |||
| 27 | + | go func() { done <- c.Run(ctx) }() | |||
| 28 | + | t.Cleanup(func() { | |||
| 29 | + | cancel() | |||
| 30 | + | if err := <-done; err != nil { | |||
| 31 | + | t.Error(err) | |||
| 32 | + | } | |||
| 33 | + | }) | |||
| 34 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "A", Width: 100, Height: 80}} | |||
| 35 | + | first := receive(t, snapshots) | |||
| 36 | + | for len(first.Outputs) == 0 { | |||
| 37 | + | first = receive(t, snapshots) | |||
| 38 | + | } | |||
| 39 | + | if len(first.Outputs) != 1 || len(first.Outputs[0].Workspaces) != 1 || !first.Outputs[0].Workspaces[0].Active { | |||
| 40 | + | t.Fatal(first) | |||
| 41 | + | } | |||
| 42 | + | id := first.Outputs[0].Workspaces[0].ID | |||
| 43 | + | client <- ports.WindowMapped{ID: 1} | |||
| 44 | + | added := receive(t, snapshots) | |||
| 45 | + | if len(added.Outputs[0].Workspaces) < 2 || added.Outputs[0].Workspaces[0].ID != id || !added.Outputs[0].Workspaces[1].Hidden { | |||
| 46 | + | t.Fatal(added) | |||
| 47 | + | } | |||
| 48 | + | spare := added.Outputs[0].Workspaces[1].ID | |||
| 49 | + | output <- ports.OutputAdded{Info: ports.OutputInfo{Name: "B", Width: 100, Height: 80}} | |||
| 50 | + | two := receive(t, snapshots) | |||
| 51 | + | if len(two.Outputs) != 2 || two.Outputs[0].Workspaces[0].ID != id { | |||
| 52 | + | t.Fatal(two) | |||
| 53 | + | } | |||
| 54 | + | client <- ports.WorkspaceActivate{ID: spare} | |||
| 55 | + | active := receive(t, snapshots) | |||
| 56 | + | if !active.Outputs[0].Workspaces[1].Active || active.Outputs[1].Workspaces[0].Active != true { | |||
| 57 | + | t.Fatal(active) | |||
| 58 | + | } | |||
| 59 | + | client <- ports.WorkspaceActivate{ID: 999999999} | |||
| 60 | + | scene(t, scenes) | |||
| 61 | + | select { | |||
| 62 | + | case v := <-snapshots: | |||
| 63 | + | t.Fatalf("unknown ID changed snapshot: %v", v) | |||
| 64 | + | default: | |||
| 65 | + | } | |||
| 66 | + | // Move the populated workspace right; its ID follows it. | |||
| 67 | + | client <- ports.WorkspaceActivate{ID: id} | |||
| 68 | + | receive(t, snapshots) | |||
| 69 | + | input <- ports.KeyEvent{Keysym: "Right", Mods: ports.ModSuper | ports.ModCtrl | ports.ModShift, Pressed: true} | |||
| 70 | + | move := receive(t, snapshots) | |||
| 71 | + | found := false | |||
| 72 | + | for _, w := range move.Outputs[1].Workspaces { | |||
| 73 | + | if w.ID == id { | |||
| 74 | + | found = true | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | if !found { | |||
| 78 | + | t.Fatalf("workspace ID lost on move: %+v", move) | |||
| 79 | + | } | |||
| 80 | + | output <- ports.OutputRemoved{Name: "B"} | |||
| 81 | + | moved := receive(t, snapshots) | |||
| 82 | + | if len(moved.Outputs) != 1 || moved.Outputs[0].Name != "A" { | |||
| 83 | + | t.Fatal(moved) | |||
| 84 | + | } | |||
| 85 | + | } | |||
M internal/ports/messages.go +18 -0
| @@ -97,6 +97,24 @@ type WindowActivate struct{ ID WindowID } | |||
| 97 | 97 | ||
| 98 | 98 | func (WindowActivate) clientEvent() {} | |
| 99 | 99 | ||
| 100 | + | // WorkspaceActivate requests showing a workspace by its stable ID, on its owning monitor. | |
| 101 | + | type WorkspaceActivate struct{ ID uint64 } | |
| 102 | + | ||
| 103 | + | func (WorkspaceActivate) clientEvent() {} | |
| 104 | + | ||
| 105 | + | // Workspaces is an immutable, latest-only inventory of output workspaces. | |
| 106 | + | type Workspaces struct{ Outputs []WorkspaceOutput } | |
| 107 | + | type WorkspaceOutput struct { | |
| 108 | + | Name string | |
| 109 | + | Workspaces []WorkspaceInfo | |
| 110 | + | } | |
| 111 | + | type WorkspaceInfo struct { | |
| 112 | + | ID uint64 | |
| 113 | + | Name string | |
| 114 | + | Index int // zero-based vertical coordinate | |
| 115 | + | Active, Hidden bool | |
| 116 | + | } | |
| 117 | + | ||
| 100 | 118 | // WindowAppID carries wayland → core an app ID set after the window mapped. | |
| 101 | 119 | type WindowAppID struct { | |
| 102 | 120 | ID WindowID | |
| @@ -97,6 +97,24 @@ type WindowActivate struct{ ID WindowID } | |||||
| 97 | 97 | ||||
| 98 | func (WindowActivate) clientEvent() {} | 98 | func (WindowActivate) clientEvent() {} | ||
| 99 | 99 | ||||
| 100 | + | // WorkspaceActivate requests showing a workspace by its stable ID, on its owning monitor. | |||
| 101 | + | type WorkspaceActivate struct{ ID uint64 } | |||
| 102 | + | ||||
| 103 | + | func (WorkspaceActivate) clientEvent() {} | |||
| 104 | + | ||||
| 105 | + | // Workspaces is an immutable, latest-only inventory of output workspaces. | |||
| 106 | + | type Workspaces struct{ Outputs []WorkspaceOutput } | |||
| 107 | + | type WorkspaceOutput struct { | |||
| 108 | + | Name string | |||
| 109 | + | Workspaces []WorkspaceInfo | |||
| 110 | + | } | |||
| 111 | + | type WorkspaceInfo struct { | |||
| 112 | + | ID uint64 | |||
| 113 | + | Name string | |||
| 114 | + | Index int // zero-based vertical coordinate | |||
| 115 | + | Active, Hidden bool | |||
| 116 | + | } | |||
| 117 | + | ||||
| 100 | // WindowAppID carries wayland → core an app ID set after the window mapped. | 118 | // WindowAppID carries wayland → core an app ID set after the window mapped. | ||
| 101 | type WindowAppID struct { | 119 | type WindowAppID struct { | ||
| 102 | ID WindowID | 120 | ID WindowID | ||