detached 5f1ad00

seat_wire_test.go RawHistoryFolder

1package neferclient_test
2
3import (
4	"encoding/binary"
5	"os"
6	"runtime"
7	"sync"
8	"testing"
9	"time"
10
11	"github.com/stretchr/testify/mock"
12	"github.com/stretchr/testify/require"
13	"golang.org/x/sys/unix"
14
15	"github.com/bnema/neferclient"
16	neferclientmocks "github.com/bnema/neferclient/mocks"
17)
18
19const (
20	seatGlobal   = 8
21	shapesGlobal = 9
22)
23
24var seatGlobals = append(append([]wireGlobal{}, surfaceGlobals...),
25	wireGlobal{seatGlobal, "wl_seat", 9}, wireGlobal{shapesGlobal, "wp_cursor_shape_manager_v1", 2})
26
27// seatPeer is the compositor side of a seat: it learns the object ids the
28// client creates and scripts input events.
29type seatPeer struct {
30	t   *testing.T
31	srv *wireServer
32
33	mu                       sync.Mutex
34	pointer, keyboard, shape uint32
35	shapeSerial, shapeValue  uint32
36	shapeSets                int
37	pointerReleased          bool
38}
39
40func newSeatPeer(t *testing.T, srv *wireServer) *seatPeer {
41	p := &seatPeer{t: t, srv: srv}
42	srv.mu.Lock()
43	srv.hook = func(obj uint32, op uint16, body []byte) {
44		srv.mu.Lock()
45		seat, shapes := srv.bound[seatGlobal].id, srv.bound[shapesGlobal].id
46		srv.mu.Unlock()
47		p.mu.Lock()
48		defer p.mu.Unlock()
49		switch {
50		case obj == seat && seat != 0 && op == 0 && len(body) == 4:
51			p.pointer = binary.LittleEndian.Uint32(body)
52		case obj == seat && seat != 0 && op == 1 && len(body) == 4:
53			p.keyboard = binary.LittleEndian.Uint32(body)
54		case obj == shapes && shapes != 0 && op == 1 && len(body) == 8:
55			p.shape = binary.LittleEndian.Uint32(body)
56		case obj == p.shape && p.shape != 0 && op == 1 && len(body) == 8:
57			p.shapeSerial = binary.LittleEndian.Uint32(body)
58			p.shapeValue = binary.LittleEndian.Uint32(body[4:])
59			p.shapeSets++
60		case obj == p.pointer && p.pointer != 0 && op == 1:
61			p.pointerReleased = true
62		}
63	}
64	srv.mu.Unlock()
65	return p
66}
67
68func (p *seatPeer) ids() (pointer, keyboard uint32) {
69	p.mu.Lock()
70	defer p.mu.Unlock()
71	return p.pointer, p.keyboard
72}
73
74func (p *seatPeer) seatID() uint32 {
75	p.srv.mu.Lock()
76	defer p.srv.mu.Unlock()
77	return p.srv.bound[seatGlobal].id
78}
79
80func fixed(v float64) uint32 { return uint32(int32(v * 256)) }
81
82func negU32(v int32) uint32 { return uint32(-v) }
83
84func (p *seatPeer) capabilities(caps uint32) {
85	p.srv.write(frame(p.seatID(), 0, appendU32(nil, caps)))
86}
87
88// keymap sends wl_keyboard.keymap with testdata/fr.xkb in a descriptor.
89func (p *seatPeer) keymap(keyboard uint32, format uint32) {
90	p.t.Helper()
91	data, err := os.ReadFile("testdata/fr.xkb")
92	require.NoError(p.t, err)
93	data = append(data, 0)
94	fd, err := unix.MemfdCreate("keymap", unix.MFD_CLOEXEC)
95	require.NoError(p.t, err)
96	defer unix.Close(fd)
97	_, err = unix.Write(fd, data)
98	require.NoError(p.t, err)
99	body := appendU32(nil, format)
100	body = appendU32(body, uint32(len(data)))
101	_, _, err = p.srv.conn.WriteMsgUnix(frame(keyboard, 0, body), unix.UnixRights(fd), nil)
102	require.NoError(p.t, err)
103}
104
105func (p *seatPeer) keyboardEnter(kb, surface uint32) {
106	body := appendU32(appendU32(nil, 1), surface)
107	body = appendU32(body, 0) // empty keys array
108	p.srv.write(frame(kb, 1, body))
109}
110
111func (p *seatPeer) key(kb, code, state uint32) []byte {
112	b := appendU32(appendU32(nil, 1), 0)
113	b = appendU32(appendU32(b, code), state)
114	return frame(kb, 3, b)
115}
116
117func seatSetup(t *testing.T) (*neferclient.Conn, *wireServer, *seatPeer, *neferclient.Surface) {
118	t.Helper()
119	c, srv := connectWire(t, seatGlobals, surfaceOutputs)
120	peer := newSeatPeer(t, srv)
121	s, err := c.NewToplevel("seat", 64, 48)
122	require.NoError(t, err)
123	require.NotNil(t, c.Seat())
124	require.NoError(t, c.Roundtrip())
125	peer.capabilities(3)
126	require.NoError(t, c.Roundtrip())
127	require.NoError(t, c.Dispatch(neferclient.NopHandler{}))
128	require.NoError(t, c.Roundtrip())
129	return c, srv, peer, s
130}
131
132func TestSeatPointerEvents(t *testing.T) {
133	c, srv, peer, s := seatSetup(t)
134	pointer, keyboard := peer.ids()
135	require.NotZero(t, pointer)
136	require.NotZero(t, keyboard)
137	surf := s.SurfaceObjectID()
138
139	var got []neferclient.PointerEvent
140	h := neferclientmocks.NewMockHandler(t)
141	h.EXPECT().Pointer(mock.Anything).Run(func(ev *neferclient.PointerEvent) { got = append(got, *ev) }).Return()
142
143	// A motion outside any surface is ignored; the enter binds the position.
144	srv.write(frame(pointer, 2, append(appendU32(appendU32(nil, 0), fixed(1)), appendU32(nil, fixed(2))...)))
145	b := appendU32(appendU32(nil, 77), surf)
146	b = appendU32(appendU32(b, fixed(10.5)), fixed(20.25))
147	srv.write(frame(pointer, 0, b)) // enter
148	srv.write(frame(pointer, 2, append(appendU32(appendU32(nil, 0), fixed(11)), appendU32(nil, fixed(21))...)))
149	srv.write(frame(pointer, 3, appendU32(appendU32(appendU32(appendU32(nil, 78), 0), 0x110), 1))) // button press
150	srv.write(frame(pointer, 4, appendU32(appendU32(appendU32(nil, 0), 0), fixed(15))))            // axis vertical
151	srv.write(frame(pointer, 9, appendU32(appendU32(nil, 1), negU32(120))))                        // value120 horizontal
152	srv.write(frame(pointer, 7, appendU32(appendU32(nil, 0), 0)))                                  // axis stop
153	srv.write(frame(pointer, 1, appendU32(appendU32(nil, 79), surf)))                              // leave
154	srv.write(frame(pointer, 2, append(appendU32(appendU32(nil, 0), fixed(3)), appendU32(nil, fixed(4))...)))
155	require.NoError(t, c.Roundtrip())
156	dispatchUntil(t, c, h, func() bool { return len(got) >= 7 })
157
158	require.Len(t, got, 7)
159	id := s.ID()
160	require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerEnter, X: 10.5, Y: 20.25, Serial: 77}, got[0])
161	require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerMotion, X: 11, Y: 21}, got[1])
162	require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerButton, X: 11, Y: 21, Button: 0x110, Pressed: true, Serial: 78}, got[2])
163	require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerAxis, X: 11, Y: 21, Axis: 0, DY: 15}, got[3])
164	require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerAxis, X: 11, Y: 21, Axis: 1, Value120: -120}, got[4])
165	require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerAxisStop, X: 11, Y: 21}, got[5])
166	require.Equal(t, neferclient.PointerEvent{Surface: id, Kind: neferclient.PointerLeave, X: 11, Y: 21}, got[6])
167}
168
169func TestSeatCursorShape(t *testing.T) {
170	c, srv, peer, s := seatSetup(t)
171	pointer, _ := peer.ids()
172	seat := c.Seat()
173	require.NoError(t, seat.SetCursor(neferclient.CursorPointer), "outside: no-op")
174	require.NoError(t, c.Roundtrip())
175	require.Zero(t, peer.shapeSets)
176
177	enter := func(serial uint32) {
178		b := appendU32(appendU32(nil, serial), s.SurfaceObjectID())
179		srv.write(frame(pointer, 0, appendU32(appendU32(b, fixed(1)), fixed(1))))
180		require.NoError(t, c.Roundtrip())
181		require.NoError(t, c.Dispatch(neferclient.NopHandler{}))
182	}
183	enter(40)
184	require.NoError(t, seat.SetCursor(neferclient.CursorText))
185	require.NoError(t, seat.SetCursor(neferclient.CursorText), "unchanged: nothing sent")
186	require.NoError(t, c.Roundtrip())
187	peer.mu.Lock()
188	require.Equal(t, 1, peer.shapeSets)
189	require.Equal(t, uint32(40), peer.shapeSerial)
190	require.Equal(t, uint32(neferclient.CursorText), peer.shapeValue)
191	peer.mu.Unlock()
192
193	enter(41) // a new enter resets the shape and carries a new serial
194	require.NoError(t, seat.SetCursor(neferclient.CursorText))
195	require.NoError(t, c.Roundtrip())
196	peer.mu.Lock()
197	require.Equal(t, 2, peer.shapeSets)
198	require.Equal(t, uint32(41), peer.shapeSerial)
199	peer.mu.Unlock()
200	require.Error(t, seat.SetCursor(0))
201	require.Error(t, seat.SetCursor(neferclient.CursorAllResize+1))
202}
203
204func TestSeatKeyboardFocusTextAndSecret(t *testing.T) {
205	c, srv, peer, s := seatSetup(t)
206	_, kb := peer.ids()
207	surf := s.SurfaceObjectID()
208	seat := c.Seat()
209
210	var keys []neferclient.KeyEvent
211	var texts []string
212	var focus []bool
213	var changed []int
214	h := neferclientmocks.NewMockHandler(t)
215	h.EXPECT().Key(mock.Anything).Run(func(ev *neferclient.KeyEvent) {
216		keys = append(keys, *ev)
217		texts = append(texts, string(ev.Text)) // the only place text is copied: the assertion of the test
218	}).Return()
219	h.EXPECT().KeyboardFocus(s.ID(), mock.Anything).Run(func(_ neferclient.SurfaceID, f bool) { focus = append(focus, f) }).Return()
220	h.EXPECT().SecretChanged(mock.Anything).Run(func(n int) { changed = append(changed, n) }).Return()
221
222	peer.keymap(kb, 1) // French AZERTY: evdev 16 is "a"
223	srv.write(frame(kb, 5, append(appendU32(nil, 0), appendU32(nil, 0)...)))
224	peer.keyboardEnter(kb, surf)
225	srv.write(peer.key(kb, 16, 1))
226	srv.write(peer.key(kb, 16, 0))
227	require.NoError(t, c.Roundtrip())
228	dispatchUntil(t, c, h, func() bool { return len(keys) >= 2 })
229	require.Equal(t, []bool{true}, focus)
230	require.Equal(t, "a", texts[0])
231	require.True(t, keys[0].Pressed)
232	require.Equal(t, s.ID(), keys[0].Surface)
233	require.Equal(t, "", texts[1])
234	require.False(t, keys[1].Pressed)
235	require.Empty(t, changed)
236
237	// With a secret buffer: the text goes only into it.
238	buf := neferclient.NewSecretBuffer(8)
239	seat.SetSecret(buf)
240	srv.write(peer.key(kb, 16, 1))
241	srv.write(peer.key(kb, 16, 0))
242	srv.write(peer.key(kb, 30, 1)) // "q" on AZERTY
243	srv.write(peer.key(kb, 30, 0))
244	srv.write(peer.key(kb, 14, 1)) // BackSpace
245	srv.write(peer.key(kb, 14, 0))
246	srv.write(peer.key(kb, 28, 1)) // Return: delivered, no text
247	require.NoError(t, c.Roundtrip())
248	dispatchUntil(t, c, h, func() bool { return len(keys) >= 2+7 })
249	for _, text := range texts[2:] {
250		require.Empty(t, text)
251	}
252	require.Equal(t, "a", string(buf.Bytes()))
253	require.Equal(t, 1, buf.Len())
254	require.Equal(t, []int{1, 2, 1}, changed)
255
256	srv.write(frame(kb, 2, appendU32(appendU32(nil, 5), surf))) // leave
257	require.NoError(t, c.Roundtrip())
258	dispatchUntil(t, c, h, func() bool { return len(focus) >= 2 })
259	require.Equal(t, []bool{true, false}, focus)
260	buf.Wipe()
261}
262
263// TestSeatKeymapFDClosedOnClose: a keymap descriptor queued but never drained
264// is closed by Close. The descriptor count after Close must not depend on
265// whether a keymap was left in the queue.
266func TestSeatKeymapFDClosedOnClose(t *testing.T) {
267	run := func(withKeymap bool) (after int) {
268		t.Run("run", func(t *testing.T) {
269			c, _, peer, _ := seatSetup(t)
270			_, kb := peer.ids()
271			if withKeymap {
272				before := openFDs(t)
273				peer.keymap(kb, 1)
274				require.NoError(t, c.Roundtrip()) // queued, not dispatched
275				require.Greater(t, openFDs(t), before, "the queued event owns a descriptor")
276			}
277			require.NoError(t, c.Close())
278			after = openFDs(t)
279		})
280		return after
281	}
282	without := run(false)
283	require.Equal(t, without, run(true), "descriptor closed with the connection")
284}
285
286// TestSeatKeymapFDClosedOnBadFormat: an unknown keymap format is reported and
287// its descriptor closed; no_keymap (0) is closed silently.
288func TestSeatKeymapFDClosedOnBadFormat(t *testing.T) {
289	c, _, peer, _ := seatSetup(t)
290	_, kb := peer.ids()
291	before := openFDs(t)
292
293	peer.keymap(kb, 0) // no_keymap: no error, no leak
294	require.NoError(t, c.Roundtrip())
295	require.NoError(t, c.Dispatch(neferclientmocks.NewMockHandler(t))) // any Handler call fails
296	require.Equal(t, before, openFDs(t))
297
298	peer.keymap(kb, 7) // unknown format
299	require.NoError(t, c.Roundtrip())
300	var errs int
301	h := neferclientmocks.NewMockHandler(t)
302	h.EXPECT().Error(mock.Anything).Run(func(error) { errs++ }).Return()
303	dispatchUntil(t, c, h, func() bool { return errs == 1 })
304	require.Equal(t, before, openFDs(t))
305}
306
307func TestSeatCapabilityLossReleasesDevices(t *testing.T) {
308	c, srv, peer, s := seatSetup(t)
309	pointer, kb := peer.ids()
310	b := appendU32(appendU32(nil, 5), s.SurfaceObjectID())
311	srv.write(frame(pointer, 0, appendU32(appendU32(b, fixed(1)), fixed(1))))
312	peer.keyboardEnter(kb, s.SurfaceObjectID())
313	require.NoError(t, c.Roundtrip())
314
315	var got []neferclient.PointerEvent
316	var focus []bool
317	h := neferclientmocks.NewMockHandler(t)
318	h.EXPECT().Pointer(mock.Anything).Run(func(ev *neferclient.PointerEvent) { got = append(got, *ev) }).Return()
319	h.EXPECT().KeyboardFocus(s.ID(), mock.Anything).Run(func(_ neferclient.SurfaceID, f bool) { focus = append(focus, f) }).Return()
320	dispatchUntil(t, c, h, func() bool { return len(got) == 1 && len(focus) == 1 })
321
322	peer.capabilities(0)
323	require.NoError(t, c.Roundtrip())
324	dispatchUntil(t, c, h, func() bool { return len(got) == 2 && len(focus) == 2 })
325	require.Equal(t, neferclient.PointerLeave, got[1].Kind, "a synthetic leave closes the focus")
326	require.False(t, focus[1])
327	require.NoError(t, c.Roundtrip())
328	peer.mu.Lock()
329	require.True(t, peer.pointerReleased)
330	peer.mu.Unlock()
331}
332
333// TestSeatRepeatThroughEpoll: the repeat timerfd is watched by the connection's
334// epoll loop and the repeat arrives through Dispatch.
335func TestSeatRepeatThroughEpoll(t *testing.T) {
336	c, srv, peer, s := seatSetup(t)
337	_, kb := peer.ids()
338	peer.keymap(kb, 1)
339	srv.write(frame(kb, 5, appendU32(appendU32(nil, 100), 1))) // 100/s after 1 ms
340	peer.keyboardEnter(kb, s.SurfaceObjectID())
341	srv.write(peer.key(kb, 16, 1))
342	require.NoError(t, c.Roundtrip())
343	var repeats int
344	var texts []string
345	h := neferclientmocks.NewMockHandler(t)
346	h.EXPECT().KeyboardFocus(mock.Anything, mock.Anything).Return()
347	h.EXPECT().Key(mock.Anything).Run(func(ev *neferclient.KeyEvent) {
348		if ev.Repeat {
349			repeats++
350			texts = append(texts, string(ev.Text))
351		}
352	}).Return()
353	dispatchUntil(t, c, h, func() bool { return repeats >= 3 })
354	require.Equal(t, "a", texts[0])
355
356	srv.write(peer.key(kb, 16, 0))
357	require.NoError(t, c.Roundtrip())
358	require.NoError(t, c.Dispatch(h))
359	n := repeats
360	time.Sleep(50 * time.Millisecond) // five repeat intervals at 100/s
361	require.NoError(t, c.Dispatch(h))
362	require.Equal(t, n, repeats, "release stops the repeat")
363}
364
365// TestAllocKey measures the steady-state key path over a real socketpair, on
366// the real code path (no mocks): key frames in, reader decode, queue, Dispatch
367// with xkb state and translation, and the Handler call.
368//
369// Every path is zero-allocation: releases, text and secret presses, and
370// modifier masks (purego-xkbcommon v0.2.0 dispatches without allocating).
371func TestAllocKey(t *testing.T) {
372	skipUnderRace(t)
373	c, srv, peer, s := seatSetup(t)
374	_, kb := peer.ids()
375	peer.keymap(kb, 1)
376	srv.write(frame(kb, 5, appendU32(appendU32(nil, 0), 0))) // repeat disabled
377	peer.keyboardEnter(kb, s.SurfaceObjectID())
378	require.NoError(t, c.Roundtrip())
379	h := &countingHandler{}
380	require.NoError(t, c.Dispatch(h))
381	require.Zero(t, h.errs)
382
383	run := func(batch []byte, events int) func() {
384		return func() {
385			if _, err := srv.conn.Write(batch); err != nil {
386				t.Fatal(err)
387			}
388			for c.QueueLen() < events {
389				runtime.Gosched()
390			}
391			if err := c.Dispatch(h); err != nil {
392				t.Fatal(err)
393			}
394		}
395	}
396	measure := func(name string, step func()) float64 {
397		step()
398		step()
399		allocs := testing.AllocsPerRun(20, step)
400		t.Logf("%s: allocs per Dispatch: %v", name, allocs)
401		return allocs
402	}
403
404	const pairs = 50
405	var releases []byte // releases of keys that were never pressed: queue + decode + dispatch only
406	for range 2 * pairs {
407		releases = append(releases, peer.key(kb, 16, 0)...)
408	}
409	require.Zero(t, measure("pipeline, 100 releases", run(releases, 2*pairs)), "the library itself allocates nothing per key")
410	require.Zero(t, h.keys, "unheld releases are not delivered")
411
412	var batch []byte
413	for range pairs {
414		batch = append(batch, peer.key(kb, 16, 1)...)
415		batch = append(batch, peer.key(kb, 16, 0)...)
416	}
417	step := run(batch, 2*pairs)
418	text := measure("text, 50 presses + releases", step)
419	require.Zero(t, text, "text presses allocate nothing")
420	require.Equal(t, "a", h.lastText)
421
422	// Modifier masks: an identical mask is a no-op and allocates nothing; a
423	// changing one updates the xkb state, also without allocating.
424	mods := func(depressed uint32) []byte {
425		b := appendU32(appendU32(nil, 1), depressed)
426		return frame(kb, 4, appendU32(appendU32(appendU32(b, 0), 0), 0))
427	}
428	var same, changing []byte
429	for range 100 {
430		same = append(same, mods(0)...)
431	}
432	for i := range 100 {
433		changing = append(changing, mods(uint32(i%2))...)
434	}
435	require.Zero(t, measure("modifiers, 100 identical masks", run(same, 100)), "identical masks cost nothing")
436	changes := measure("modifiers, 100 alternating masks", run(changing, 100))
437	require.Zero(t, changes, "mask changes allocate nothing")
438
439	buf := neferclient.NewSecretBuffer(4096)
440	c.Seat().SetSecret(buf)
441	secret := measure("secret, 50 presses + releases", step)
442	require.Zero(t, secret, "secret presses allocate nothing")
443	require.Empty(t, h.lastText, "no text reaches the handler in secret mode")
444	require.Positive(t, buf.Len())
445	buf.Wipe()
446	require.Zero(t, h.errs)
447}
448
449// TestAllocPointer measures the pointer path over a real socketpair: a batch
450// of motion, button and axis frames in, reader decode, queue, Dispatch and the
451// Handler call. Pointer events need no xkb, so the whole path allocates
452// nothing.
453func TestAllocPointer(t *testing.T) {
454	skipUnderRace(t)
455	c, srv, peer, s := seatSetup(t)
456	pointer, _ := peer.ids()
457	enter := appendU32(appendU32(nil, 5), s.SurfaceObjectID())
458	srv.write(frame(pointer, 0, appendU32(appendU32(enter, fixed(1)), fixed(1))))
459	require.NoError(t, c.Roundtrip())
460	h := &countingHandler{}
461	require.NoError(t, c.Dispatch(h))
462	require.Equal(t, 1, h.pointers)
463
464	const rounds = 40
465	var batch []byte
466	for i := range rounds {
467		batch = append(batch, frame(pointer, 2, append(appendU32(appendU32(nil, 0), fixed(float64(i))), appendU32(nil, fixed(2))...))...)
468		batch = append(batch, frame(pointer, 3, appendU32(appendU32(appendU32(appendU32(nil, 6), 0), 0x110), uint32(i%2)))...)
469		batch = append(batch, frame(pointer, 4, appendU32(appendU32(appendU32(nil, 0), 0), fixed(2)))...)
470		batch = append(batch, frame(pointer, 9, appendU32(appendU32(nil, 0), 120))...)
471	}
472	step := func() {
473		if _, err := srv.conn.Write(batch); err != nil {
474			t.Fatal(err)
475		}
476		for c.QueueLen() < 4*rounds {
477			runtime.Gosched()
478		}
479		if err := c.Dispatch(h); err != nil {
480			t.Fatal(err)
481		}
482	}
483	step()
484	step()
485	before := h.pointers
486	allocs := testing.AllocsPerRun(20, step)
487	t.Logf("allocs per Dispatch of %d pointer events: %v", 4*rounds, allocs)
488	require.Zero(t, allocs)
489	require.Greater(t, h.pointers, before)
490	require.Zero(t, h.errs)
491}

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 1

Tags 4

↑↓ moveenter openesc close