1package wayland
2
3import (
4 "cmp"
5 "context"
6 "slices"
7 "strconv"
8 "unicode"
9 "unicode/utf8"
10
11 extworkspace "github.com/bnema/wlturbo/protocol/workspace"
12
13 "github.com/bnema/nefercap/internal/ports"
14)
15
16// ext-workspace-v1 client. Only the state needed to list workspaces and to
17// name one as a capture source is kept; nothing is ever requested from the
18// compositor (no activate, no commit).
19
20const (
21 workspaceVersion = 1
22 maxWorkspaces = 256 // bound of the inventory
23 maxNameLen = 256 // bytes, for workspace names
24 maxGroupOutputs = 64
25
26 maxInterned = 4 * maxWorkspaces // id strings remembered across removals
27)
28
29// workspaceState is the ext-workspace inventory of a Source.
30type workspaceState struct {
31 mgr *extworkspace.ExtWorkspaceManager // nil when the compositor has no ext-workspace
32 handles map[uint32]*workspace // by proxy ID
33 byID map[uint64]*workspace // by Source-local identity
34 groups map[uint32]*workspaceGroup
35 next uint64 // last identity handed out; identities start at 1
36 interned map[string]uint64 // id string to identity, bounded by maxInterned
37 overflow bool // the compositor announced more objects than maxWorkspaces
38 shown uint64 // workspace last verified as displayed, without the extension
39}
40
41// watchWorkspaces subscribes to the creation events of the manager.
42func (s *Source) watchWorkspaces(m *extworkspace.ExtWorkspaceManager) {
43 m.OnWorkspaceGroup(func(proxy *extworkspace.ExtWorkspaceGroupHandle) {
44 // wlturbo registered the proxy already, which absorbs its events.
45 if len(s.ws.groups) >= maxWorkspaces {
46 s.ws.overflow = true
47 return
48 }
49 g := &workspaceGroup{proxy: proxy, outputs: make(map[uint32]bool)}
50 s.ws.groups[proxy.ID()] = g
51 s.watchGroup(g)
52 })
53 m.OnWorkspace(func(proxy *extworkspace.ExtWorkspaceHandle) {
54 if len(s.ws.handles) >= maxWorkspaces {
55 s.ws.overflow = true
56 return
57 }
58 w := &workspace{proxy: proxy}
59 s.ws.handles[proxy.ID()] = w
60 s.watchWorkspace(w)
61 })
62 m.OnDone(s.ws.assignIDs)
63}
64
65type workspaceGroup struct {
66 proxy *extworkspace.ExtWorkspaceGroupHandle
67 outputs map[uint32]bool // wl_output proxy IDs
68}
69
70func (s *Source) watchGroup(g *workspaceGroup) {
71 g.proxy.OnOutputEnter(func(id uint32) {
72 if len(g.outputs) < maxGroupOutputs {
73 g.outputs[id] = true
74 }
75 })
76 g.proxy.OnOutputLeave(func(id uint32) { delete(g.outputs, id) })
77 g.proxy.OnWorkspaceEnter(func(id uint32) {
78 if w := s.ws.handles[id]; w != nil {
79 w.group = g
80 }
81 })
82 g.proxy.OnWorkspaceLeave(func(id uint32) {
83 if w := s.ws.handles[id]; w != nil && w.group == g {
84 w.group = nil
85 }
86 })
87 g.proxy.OnRemoved(func() {
88 delete(s.ws.groups, g.proxy.ID())
89 for _, w := range s.ws.handles {
90 if w.group == g {
91 w.group = nil
92 }
93 }
94 _ = g.proxy.Destroy()
95 })
96}
97
98// workspace is one ext_workspace_handle_v1. Its proxy is the argument of
99// create_workspace_source.
100type workspace struct {
101 proxy *extworkspace.ExtWorkspaceHandle
102 idStr string // the id event: empty for a temporary workspace
103 name string
104 state uint32
105 group *workspaceGroup
106 id uint64 // Source-local identity, assigned at the first done
107 removed bool
108 frame *workspaceFrame // NeferWL's extension only; nil until asked for
109}
110
111// watchWorkspace subscribes to the events of w. Coordinates are not used:
112// nothing here depends on the grid.
113func (s *Source) watchWorkspace(w *workspace) {
114 w.proxy.OnId(func(id string) {
115 if w.idStr == "" {
116 w.idStr = id
117 }
118 })
119 w.proxy.OnName(func(n string) {
120 if validName(n) {
121 w.name = n
122 }
123 })
124 w.proxy.OnState(func(state uint32) { w.state = state })
125 w.proxy.OnRemoved(func() {
126 w.removed = true
127 s.releaseWorkspaceFrame(w)
128 delete(s.ws.handles, w.proxy.ID())
129 if w.id != 0 && s.ws.byID[w.id] == w {
130 delete(s.ws.byID, w.id)
131 }
132 _ = w.proxy.Destroy()
133 })
134}
135
136// assignIDs gives every workspace announced since the last done its
137// identity. The id event is an opaque string: it is interned into a number
138// local to this Source, from one counter, so identities cannot collide and the
139// string is never parsed. The same string keeps its identity while the Source
140// lives (up to maxInterned strings). A workspace without an id, or whose id
141// is already held by a live workspace, is temporary: it gets a fresh number
142// that is not remembered.
143func (ws *workspaceState) assignIDs() {
144 fresh := make([]*workspace, 0, len(ws.handles))
145 for _, w := range ws.handles {
146 if w.id == 0 && !w.removed {
147 fresh = append(fresh, w)
148 }
149 }
150 // Map order is random: numbers follow the announcement order.
151 slices.SortFunc(fresh, func(a, b *workspace) int { return cmp.Compare(a.proxy.ID(), b.proxy.ID()) })
152 for _, w := range fresh {
153 w.id = ws.intern(w.idStr)
154 ws.byID[w.id] = w
155 }
156}
157
158func (ws *workspaceState) intern(idStr string) uint64 {
159 if idStr != "" {
160 if id, ok := ws.interned[idStr]; ok {
161 if ws.byID[id] == nil {
162 return id
163 }
164 } else if len(ws.interned) < maxInterned {
165 ws.next++
166 ws.interned[idStr] = ws.next
167 return ws.next
168 }
169 }
170 ws.next++
171 return ws.next
172}
173
174// validName accepts a bounded, valid UTF-8, single-line name without control
175// or bidirectional formatting characters.
176func validName(n string) bool {
177 if len(n) > maxNameLen || !utf8.ValidString(n) {
178 return false
179 }
180 for _, r := range n {
181 switch {
182 case unicode.IsControl(r), r == 0x2028, r == 0x2029, r == 0x061C, r == 0x200E, r == 0x200F,
183 r >= 0x202A && r <= 0x202E, r >= 0x2066 && r <= 0x2069:
184 return false
185 }
186 }
187 return true
188}
189
190func outputName(o *output) string {
191 if o.name == "" { // wl_output older than version 4 has no name
192 return "output-" + strconv.FormatUint(uint64(o.id), 10)
193 }
194 return o.name
195}
196
197// forgetOutput drops a removed wl_output proxy from every group.
198func (ws *workspaceState) forgetOutput(proxyID uint32) {
199 for _, g := range ws.groups {
200 delete(g.outputs, proxyID)
201 }
202}
203
204// bindWorkspaces binds ext-workspace-v1 when the compositor offers it.
205func (s *Source) bindWorkspaces() error {
206 s.ws.handles = make(map[uint32]*workspace)
207 s.ws.byID = make(map[uint64]*workspace)
208 s.ws.groups = make(map[uint32]*workspaceGroup)
209 s.ws.interned = make(map[string]uint64)
210 m := extworkspace.NewExtWorkspaceManager(s.wl)
211 ok, err := s.bindOptional(extworkspace.ExtWorkspaceManagerInterface, workspaceVersion, m)
212 if err != nil || !ok {
213 return err
214 }
215 s.watchWorkspaces(m)
216 s.ws.mgr = m
217 return nil
218}
219
220// Workspaces lists the workspaces ext-workspace-v1 advertises, one entry per
221// output of the workspace's group, ordered by identity. It returns nil when
222// the compositor has no ext-workspace.
223func (s *Source) Workspaces(ctx context.Context) ([]ports.Workspace, error) {
224 s.opMu.Lock()
225 defer s.opMu.Unlock()
226 if err := s.begin(ctx); err != nil {
227 return nil, err
228 }
229 err := s.settle()
230 if err == nil {
231 // The frame of a workspace comes from NeferWL's extension: one
232 // object per workspace, answered at creation.
233 var asked bool
234 if asked, err = s.requestFrames(); asked && err == nil {
235 err = s.roundtrip()
236 }
237 }
238 var list []ports.Workspace
239 if err == nil {
240 list = s.workspaceList()
241 }
242 if err = s.finish(ctx, err); err != nil {
243 return nil, err
244 }
245 return list, nil
246}
247
248func (s *Source) workspaceList() []ports.Workspace {
249 if s.ws.mgr == nil || s.ws.overflow {
250 return nil
251 }
252 var list []ports.Workspace
253 for _, w := range s.ws.byID {
254 if w.removed || w.group == nil {
255 continue
256 }
257 for proxyID := range w.group.outputs {
258 o := s.byProxy[proxyID]
259 if o == nil {
260 continue
261 }
262 list = append(list, ports.Workspace{
263 ID: w.id, OutputID: o.id, Name: w.name,
264 Region: workspaceRegion(w, o),
265 Active: w.state&extworkspace.STATE_ACTIVE != 0,
266 })
267 }
268 }
269 slices.SortFunc(list, func(a, b ports.Workspace) int {
270 return cmp.Or(cmp.Compare(a.ID, b.ID), cmp.Compare(a.OutputID, b.OutputID))
271 })
272 return list
273}
274
275// workspaceRegion is where the workspace is on o, in output-local logical
276// pixels: its frame from NeferWL's extension, clipped to the output, else the
277// whole output (ext-workspace carries no geometry). NeferWL computes the frame
278// clamped to the output and centred, so it never lies outside it; the clip and
279// the whole-output fallback are safeguards only (core's Picker.clip is the
280// single clip applied to the selection).
281func workspaceRegion(w *workspace, o *output) ports.Region {
282 lw, lh := o.logicalSize()
283 whole := ports.Region{Width: lw, Height: lh}
284 r, ok := w.frame.region()
285 if !ok {
286 return whole
287 }
288 x0, y0 := max(r.X, 0), max(r.Y, 0)
289 x1, y1 := min(r.X+r.Width, lw), min(r.Y+r.Height, lh)
290 if x1 <= x0 || y1 <= y0 {
291 return whole
292 }
293 return ports.Region{X: x0, Y: y0, Width: x1 - x0, Height: y1 - y0}
294}
295
296// workspaceKey resolves a workspace target. With NeferWL's extension the
297// session follows the workspace itself, shown or not. Without it the
298// workspace is captured as its output, and only while it is displayed: that is
299// checked when the workspace is first targeted, so the recording then follows
300// the output whatever it shows, and never starts on hidden content.
301func (s *Source) workspaceKey(t ports.Target, out *output) (sessionKey, ports.Region, error) {
302 if t.Region != (ports.Region{}) {
303 return sessionKey{}, ports.Region{}, ports.ErrInvalidRegion
304 }
305 w := s.ws.byID[t.WorkspaceID]
306 if w == nil || w.removed {
307 return sessionKey{}, ports.Region{}, ports.ErrWorkspaceUnavailable
308 }
309 if s.ext.source != nil {
310 return sessionKey{output: out.id, workspace: w.id}, ports.Region{}, nil
311 }
312 if s.ws.shown != w.id {
313 if err := s.roundtrip(); err != nil {
314 return sessionKey{}, ports.Region{}, err
315 }
316 if w.removed || w.state&extworkspace.STATE_ACTIVE == 0 || w.group == nil || !w.group.outputs[out.proxy.ID()] {
317 return sessionKey{}, ports.Region{}, ports.ErrWorkspaceUnavailable
318 }
319 s.ws.shown = w.id
320 }
321 return sessionKey{output: out.id}, ports.Region{}, nil
322}