v0.2.0 a558f49

workspaces.go RawHistoryFolder

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).
278func workspaceRegion(w *workspace, o *output) ports.Region {
279	lw, lh := o.logicalSize()
280	whole := ports.Region{Width: lw, Height: lh}
281	r, ok := w.frame.region()
282	if !ok {
283		return whole
284	}
285	x0, y0 := max(r.X, 0), max(r.Y, 0)
286	x1, y1 := min(r.X+r.Width, lw), min(r.Y+r.Height, lh)
287	if x1 <= x0 || y1 <= y0 {
288		return whole
289	}
290	return ports.Region{X: x0, Y: y0, Width: x1 - x0, Height: y1 - y0}
291}
292
293// workspaceKey resolves a workspace target. With NeferWL's extension the
294// session follows the workspace itself, shown or not. Without it the
295// workspace is captured as its output, and only while it is displayed: that is
296// checked when the workspace is first targeted, so the recording then follows
297// the output whatever it shows, and never starts on hidden content.
298func (s *Source) workspaceKey(t ports.Target, out *output) (sessionKey, ports.Region, error) {
299	if t.Region != (ports.Region{}) {
300		return sessionKey{}, ports.Region{}, ports.ErrInvalidRegion
301	}
302	w := s.ws.byID[t.WorkspaceID]
303	if w == nil || w.removed {
304		return sessionKey{}, ports.Region{}, ports.ErrWorkspaceUnavailable
305	}
306	if s.ext.source != nil {
307		return sessionKey{output: out.id, workspace: w.id}, ports.Region{}, nil
308	}
309	if s.ws.shown != w.id {
310		if err := s.roundtrip(); err != nil {
311			return sessionKey{}, ports.Region{}, err
312		}
313		if w.removed || w.state&extworkspace.STATE_ACTIVE == 0 || w.group == nil || !w.group.outputs[out.proxy.ID()] {
314			return sessionKey{}, ports.Region{}, ports.ErrWorkspaceUnavailable
315		}
316		s.ws.shown = w.id
317	}
318	return sessionKey{output: out.id}, ports.Region{}, nil
319}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close