v0.4.0 0b9cfda

kittygraphics_test.go RawHistoryFolder

1package kittygraphics
2
3import (
4	"bytes"
5	"encoding/base64"
6	"errors"
7	"image"
8	"image/color"
9	"image/png"
10	"math/rand"
11	"os"
12	"strconv"
13	"testing"
14
15	"github.com/bnema/vev-vt/graphics"
16)
17
18func apc(header, payload string) []byte {
19	return append([]byte("\x1b_G"+header+";"+payload+"\x1b\\"), []byte{}...)
20}
21
22func pngBytes(t *testing.T, width, height int) []byte {
23	t.Helper()
24	img := image.NewRGBA(image.Rect(0, 0, width, height))
25	img.SetRGBA(0, 0, color.RGBA{R: 10, G: 20, B: 30, A: 255})
26	var out bytes.Buffer
27	if err := png.Encode(&out, img); err != nil {
28		t.Fatal(err)
29	}
30	return out.Bytes()
31}
32
33func TestParserRecognizesSplitPrefixAndPreservesText(t *testing.T) {
34	p := NewParser()
35	var events []Event
36	for _, b := range []byte("before\x1b_Ga=q,i=7;\x1b\\after") {
37		events = append(events, p.Feed([]byte{b})...)
38	}
39	var textBefore, textAfter []byte
40	var command Event
41	for _, event := range events {
42		if event.Kind == EventText && command.Kind == 0 {
43			textBefore = append(textBefore, event.Text...)
44		}
45		if event.Kind == EventCommand {
46			command = event
47		}
48		if event.Kind == EventText && command.Kind != 0 {
49			textAfter = append(textAfter, event.Text...)
50		}
51	}
52	if string(textBefore) != "before" || command.Kind != EventCommand || string(textAfter) != "after" {
53		t.Fatalf("events = %#v", events)
54	}
55	if command.Command.Controls.Action != ActionQuery || command.Command.Controls.ImageID != 7 {
56		t.Fatalf("command = %#v", command.Command)
57	}
58}
59
60func TestSessionCapabilityQueryValidatesWithoutPersisting(t *testing.T) {
61	scene := graphics.NewScene(graphics.Limits{})
62	session := NewSession(scene)
63
64	result, err := session.Feed(apc("a=q,i=31,t=d,f=24,s=1,v=1", "AAAA"))
65	if err != nil {
66		t.Fatal(err)
67	}
68	if got := string(result.Bytes()); got != "\x1b_Gi=31;OK\x1b\\" {
69		t.Fatalf("response = %q", got)
70	}
71	if got := scene.Usage(); got != (graphics.Usage{}) {
72		t.Fatalf("query persisted graphics state: %#v", got)
73	}
74}
75
76func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) {
77	data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
78	if err != nil {
79		t.Fatal(err)
80	}
81	scene := graphics.NewScene(graphics.Limits{})
82	session := NewSession(scene)
83	// Feed split boundaries deliberately unrelated to APC boundaries. This
84	// models the short writes observed from the current kitten/icat fixture.
85	rng := rand.New(rand.NewSource(7))
86	var result Result
87	for len(data) != 0 {
88		n := 1 + rng.Intn(4096)
89		if n > len(data) {
90			n = len(data)
91		}
92		part, err := session.Feed(data[:n])
93		if err != nil {
94			t.Fatal(err)
95		}
96		result.Events = append(result.Events, part.Events...)
97		result.Responses = append(result.Responses, part.Responses...)
98		result.Mutations = append(result.Mutations, part.Mutations...)
99		data = data[n:]
100	}
101	if len(result.Mutations) != 1 || result.Mutations[0].Kind != MutationPlacement {
102		t.Fatalf("mutations = %#v", result.Mutations)
103	}
104	if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 || got.DecodedPixels != 40000 {
105		t.Fatalf("usage = %#v", got)
106	}
107	if len(result.Responses) != 0 {
108		t.Fatalf("quiet fixture response = %q", result.Bytes())
109	}
110	if _, ok := session.Image(0); ok {
111		t.Fatal("unidentified fixture image was retained as a reusable mapping")
112	}
113}
114
115func TestDecodeBase64AcceptsStandardAndRaw(t *testing.T) {
116	want := []byte{0, 1, 2, 253, 254, 255}
117	standard := base64.StdEncoding.EncodeToString(want)
118	raw := base64.RawStdEncoding.EncodeToString(want)
119	for _, encoded := range []string{standard, raw} {
120		got, err := DecodeBase64([]byte(encoded))
121		if err != nil || !bytes.Equal(got, want) {
122			t.Fatalf("DecodeBase64(%q) = %x, %v", encoded, got, err)
123		}
124	}
125	if _, err := DecodeBase64([]byte("not+base64?")); !errors.Is(err, ErrInvalidBase64) {
126		t.Fatalf("invalid base64 error = %v", err)
127	}
128}
129
130func TestSessionAcceptsRawBase64AndMapsPlacement(t *testing.T) {
131	scene := graphics.NewScene(graphics.Limits{})
132	session := NewSession(scene)
133	payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
134	result, err := session.Feed(apc("a=t,i=9,f=32,s=1,v=1,q=2", payload))
135	if err != nil {
136		t.Fatal(err)
137	}
138	if len(result.Responses) != 0 || len(result.Mutations) != 1 {
139		t.Fatalf("result = %#v", result)
140	}
141	asset, ok := session.Image(9)
142	if !ok {
143		t.Fatal("image mapping missing")
144	}
145	if _, err := session.Feed(apc("a=p,i=9,p=4,X=5,Y=6,c=1,r=1", "")); err != nil {
146		t.Fatal(err)
147	}
148	placement, ok := session.Placement(9, 4)
149	if !ok {
150		t.Fatal("placement mapping missing")
151	}
152	view, ok := scene.Snapshot().Placement(placement)
153	if !ok || view.AssetID() != asset || view.Destination() != (graphics.PixelRect{X: 5, Y: 6, Width: 1, Height: 1}) {
154		t.Fatalf("placement = %#v, ok=%v", view, ok)
155	}
156}
157
158func TestPlacementIDsAreScopedToTheirImage(t *testing.T) {
159	scene := graphics.NewScene(graphics.Limits{})
160	session := NewSession(scene)
161	payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
162	for _, imageID := range []uint64{1, 2} {
163		if _, err := session.Feed(apc("a=t,i="+strconv.FormatUint(imageID, 10)+",f=32,s=1,v=1,q=2", payload)); err != nil {
164			t.Fatal(err)
165		}
166		if _, err := session.Feed(apc("a=p,i="+strconv.FormatUint(imageID, 10)+",p=7,q=2", "")); err != nil {
167			t.Fatal(err)
168		}
169	}
170	first, ok := session.Placement(1, 7)
171	if !ok {
172		t.Fatal("first placement missing")
173	}
174	second, ok := session.Placement(2, 7)
175	if !ok || first == second {
176		t.Fatalf("placement mapping collision: first=%v second=%v ok=%v", first, second, ok)
177	}
178	if got := scene.Usage().Placements; got != 2 {
179		t.Fatalf("placements = %d, want 2", got)
180	}
181}
182
183func TestParseAPCAcceptsPayloadFreeCommands(t *testing.T) {
184	for _, raw := range []string{"\x1b_Ga=d,d=a\x1b\\", "\x1b_Ga=p,i=1\x1b\\"} {
185		command, err := ParseAPC([]byte(raw))
186		if err != nil {
187			t.Fatalf("ParseAPC(%q) error = %v", raw, err)
188		}
189		if len(command.Payload) != 0 {
190			t.Fatalf("ParseAPC(%q) payload = %q", raw, command.Payload)
191		}
192	}
193}
194
195func TestMalformedTruncatedInterleavedAndOversizeAreBounded(t *testing.T) {
196	if _, err := ParseCommand([]byte("a=q,a=t;")); !errors.Is(err, ErrDuplicateControl) {
197		t.Fatalf("duplicate control error = %v", err)
198	}
199	if _, err := ParseCommand([]byte("a=q,i=18446744073709551616;")); !errors.Is(err, ErrIntegerOverflow) {
200		t.Fatalf("integer overflow error = %v", err)
201	}
202	if _, err := ParseCommand([]byte("a=q,q=256;")); !errors.Is(err, ErrInvalidCommand) {
203		t.Fatalf("out-of-range quiet error = %v", err)
204	}
205	if _, err := ParseCommand([]byte("a=q,i=1,I=2;")); !errors.Is(err, ErrInvalidCommand) {
206		t.Fatalf("mixed image selector error = %v", err)
207	}
208
209	p := NewParser(Limits{MaxAPCBytes: 8})
210	events := p.Feed(apc("a=q", "0123456789"))
211	if len(events) != 1 || !errors.Is(events[0].Err, ErrAPCTooLarge) {
212		t.Fatalf("oversize events = %#v", events)
213	}
214	p = NewParser()
215	if got := p.Feed([]byte("\x1b_Ga=q;payload")); len(got) != 0 {
216		t.Fatalf("truncated feed events = %#v", got)
217	}
218	got := p.Finish()
219	if len(got) != 1 || !errors.Is(got[0].Err, ErrAPCTruncated) {
220		t.Fatalf("truncated finish events = %#v", got)
221	}
222
223	session := NewSession(graphics.NewScene(graphics.Limits{}))
224	first := apc("a=t,i=1,f=32,s=1,v=1,m=1,q=2", "AQ")
225	if _, err := session.Feed(first); err != nil {
226		t.Fatal(err)
227	}
228	if _, err := session.Feed(apc("a=t,i=2,m=0,q=2", "ID")); !errors.Is(err, ErrInterleavedUpload) {
229		t.Fatalf("interleaved error = %v", err)
230	}
231	if _, err := session.Feed(apc("a=t,m=0,q=2", "IDBA")); err != nil {
232		t.Fatal(err)
233	}
234}
235
236func TestSessionTruncationAndDeleteAbortPendingUpload(t *testing.T) {
237	for _, action := range []string{"finish", "delete"} {
238		t.Run(action, func(t *testing.T) {
239			scene := graphics.NewScene(graphics.Limits{})
240			session := NewSession(scene)
241			if _, err := session.Feed(apc("a=T,i=1,f=32,s=1,v=1,m=1,q=2", "AQ")); err != nil {
242				t.Fatal(err)
243			}
244			if action == "finish" {
245				if _, err := session.Finish(); err != nil {
246					t.Fatalf("Finish error = %v", err)
247				}
248			} else if _, err := session.Feed(apc("a=d,q=2", "")); err != nil {
249				t.Fatal(err)
250			}
251			if _, err := session.Feed(apc("m=0,q=2", "IDBA")); err == nil {
252				t.Fatal("stale continuation unexpectedly succeeded")
253			}
254			if got := scene.Usage(); got != (graphics.Usage{}) {
255				t.Fatalf("stale continuation mutated scene: %#v", got)
256			}
257		})
258	}
259}
260
261func TestMalformedPNGDoesNotMutateScene(t *testing.T) {
262	good := pngBytes(t, 1, 1)
263	scene := graphics.NewScene(graphics.Limits{})
264	session := NewSession(scene)
265	if _, err := session.Feed(apc("a=t,i=1,f=100,q=2", base64.RawStdEncoding.EncodeToString(good[:len(good)-2]))); !errors.Is(err, ErrInvalidPNG) {
266		t.Fatalf("malformed PNG error = %v", err)
267	}
268	if got := scene.Usage(); got.Assets != 0 || got.Placements != 0 {
269		t.Fatalf("malformed PNG mutated scene: %#v", got)
270	}
271}
272
273func TestImageGeometryRejectsOverflowBeforeByteArithmetic(t *testing.T) {
274	controls := Controls{Width: ^uint64(0), HasWidth: true, Height: ^uint64(0), HasHeight: true}
275	limits := Limits{MaxDimension: ^uint64(0), MaxDecodedPixels: ^uint64(0)}
276	if _, _, err := imageGeometry(nil, FormatRGBA, controls, limits); !errors.Is(err, ErrPayloadTooLarge) {
277		t.Fatalf("overflowing geometry error = %v", err)
278	}
279}
280
281func TestChunkedTransmitAndDisplayRetainsDisplayAction(t *testing.T) {
282	scene := graphics.NewScene(graphics.Limits{})
283	session := NewSession(scene)
284	first := apc("a=T,i=1,f=32,s=1,v=1,m=1,q=2", "AQ")
285	if _, err := session.Feed(first); err != nil {
286		t.Fatal(err)
287	}
288	last := apc("m=0,q=2", "IDBA")
289	result, err := session.Feed(last)
290	if err != nil {
291		t.Fatal(err)
292	}
293	if len(result.Mutations) != 1 || result.Mutations[0].Kind != MutationPlacement {
294		t.Fatalf("mutations = %#v", result.Mutations)
295	}
296	if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 {
297		t.Fatalf("usage = %#v", got)
298	}
299}
300
301func TestControlsIgnoreExtensionsAndBoundIntegerFields(t *testing.T) {
302	controls, err := ParseControls([]byte("a=p,i=1,N=1,S=2,O=3"))
303	if err != nil || controls.ImageID != 1 {
304		t.Fatalf("extension controls = %#v, %v", controls, err)
305	}
306	if _, err := ParseControls([]byte("a=p,s=4294967296")); !errors.Is(err, ErrIntegerOverflow) {
307		t.Fatalf("out-of-range uint32 control error = %v", err)
308	}
309	if _, err := ParseControls([]byte("a=p,z=2147483648")); !errors.Is(err, ErrIntegerOverflow) {
310		t.Fatalf("out-of-range int32 control error = %v", err)
311	}
312}
313
314func TestImplicitPlacementIDsStopAtKittyLimit(t *testing.T) {
315	session := NewSession(nil)
316	session.nextPlacement = MaxKittyID + 1
317	if _, ok := session.takePlacementID(1); ok {
318		t.Fatal("implicit placement ID exceeded Kitty's uint32 namespace")
319	}
320}
321
322func TestUnsupportedRelativeAndCellDeleteControlsFailClosed(t *testing.T) {
323	session := NewSession(graphics.NewScene(graphics.Limits{}))
324	payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
325	if _, err := session.Feed(apc("a=t,i=1,f=32,s=1,v=1,q=2", payload)); err != nil {
326		t.Fatal(err)
327	}
328	for _, header := range []string{"a=p,i=1,P=2", "a=p,i=1,H=1", "a=d,d=p,x=1,y=1", "a=d,d=P,x=1,y=1"} {
329		if _, err := session.Feed(apc(header, "")); !errors.Is(err, ErrUnsupported) {
330			t.Fatalf("%q error = %v, want ErrUnsupported", header, err)
331		}
332	}
333}
334
335func TestPNGDecodedByteBudgetAndAssetFormat(t *testing.T) {
336	data := pngBytes(t, 2, 2)
337	if _, _, err := imageGeometry(data, FormatPNG, Controls{}, normalizeLimits(Limits{MaxDecodedBytes: 15})); !errors.Is(err, ErrPayloadTooLarge) {
338		t.Fatalf("PNG byte-budget error = %v", err)
339	}
340	scene := graphics.NewScene(graphics.Limits{})
341	session := NewSession(scene)
342	payload := base64.RawStdEncoding.EncodeToString(data)
343	if _, err := session.Feed(apc("a=t,i=1,f=100,q=2", payload)); err != nil {
344		t.Fatal(err)
345	}
346	assetID, ok := session.Image(1)
347	if !ok {
348		t.Fatal("image mapping missing")
349	}
350	asset, ok := scene.Snapshot().Asset(assetID)
351	if !ok || asset.Format() != graphics.AssetFormatPNG || asset.Blob().Format != graphics.AssetFormatPNG {
352		t.Fatalf("asset format = %#v, ok=%v", asset, ok)
353	}
354}
355
356func TestPlacementFailureEmitsStableProtocolError(t *testing.T) {
357	scene := graphics.NewScene(graphics.Limits{})
358	session := NewSession(scene)
359	if _, err := session.Feed(apc("a=t,i=1,f=32,s=1,v=1,q=2", "AQIDBA")); err != nil {
360		t.Fatal(err)
361	}
362	result, err := session.Feed(apc("a=p,i=1,p=7,w=2,q=0", ""))
363	if !errors.Is(err, graphics.ErrInvalidPlacement) {
364		t.Fatalf("placement error = %v", err)
365	}
366	if got := result.Bytes(); string(got) != "\x1b_Gi=1;EINVAL\x1b\\" {
367		t.Fatalf("placement response = %q", got)
368	}
369	if len(result.Mutations) != 0 || scene.Usage().Placements != 0 {
370		t.Fatalf("failed placement mutated state: result=%#v usage=%#v", result, scene.Usage())
371	}
372}
373
374func TestFailedImageReplacementPreservesAssetAndPlacements(t *testing.T) {
375	scene := graphics.NewScene(graphics.Limits{MaxEncodedBytes: 4, MaxDecodedPixels: 4})
376	session := NewSession(scene)
377	if _, err := session.Feed(apc("a=t,i=1,f=32,s=1,v=1,q=2", "AQIDBA")); err != nil {
378		t.Fatal(err)
379	}
380	if _, err := session.Feed(apc("a=p,i=1,p=7,q=2", "")); err != nil {
381		t.Fatal(err)
382	}
383	assetID, ok := session.Image(1)
384	if !ok {
385		t.Fatal("original image mapping missing")
386	}
387	placementID, ok := session.Placement(1, 7)
388	if !ok {
389		t.Fatal("original placement mapping missing")
390	}
391	before := scene.Snapshot()
392	result, err := session.Feed(apc("a=t,i=1,f=32,s=2,v=1,q=0", "AQIDBAUGBwg"))
393	if !errors.Is(err, graphics.ErrEncodedBudget) {
394		t.Fatalf("replacement error = %v", err)
395	}
396	if got := result.Bytes(); string(got) != "\x1b_Gi=1;E2BIG\x1b\\" {
397		t.Fatalf("replacement response = %q", got)
398	}
399	if got, ok := session.Image(1); !ok || got != assetID {
400		t.Fatalf("replacement changed image mapping: id=%v ok=%v want=%v", got, ok, assetID)
401	}
402	if got, ok := session.Placement(1, 7); !ok || got != placementID {
403		t.Fatalf("replacement changed placement mapping: id=%v ok=%v want=%v", got, ok, placementID)
404	}
405	if scene.Snapshot().Generation() != before.Generation() || scene.Usage() != before.Usage() {
406		t.Fatalf("failed replacement changed scene: before=%#v after=%#v", before.Usage(), scene.Usage())
407	}
408	if _, ok := scene.Snapshot().Placement(placementID); !ok {
409		t.Fatal("failed replacement removed original placement")
410	}
411}
412
413func TestFailedDisplayedImageReplacementPreservesOldPlacement(t *testing.T) {
414	scene := graphics.NewScene(graphics.Limits{})
415	session := NewSession(scene)
416	if _, err := session.Feed(apc("a=T,i=1,f=32,s=1,v=1,q=2", "AQIDBA")); err != nil {
417		t.Fatal(err)
418	}
419	assetID, ok := session.Image(1)
420	if !ok {
421		t.Fatal("original image mapping missing")
422	}
423	placementID, ok := session.Placement(1, 1)
424	if !ok {
425		t.Fatal("original placement mapping missing")
426	}
427
428	result, err := session.Feed(apc("a=T,i=1,f=32,s=1,v=1,w=2,q=0", "AQIDBA"))
429	if !errors.Is(err, graphics.ErrInvalidPlacement) {
430		t.Fatalf("replacement placement error = %v", err)
431	}
432	if got := result.Bytes(); string(got) != "\x1b_Gi=1;EINVAL\x1b\\" {
433		t.Fatalf("replacement response = %q", got)
434	}
435	if got, ok := session.Image(1); !ok || got != assetID {
436		t.Fatalf("failed displayed replacement changed image: id=%v ok=%v want=%v", got, ok, assetID)
437	}
438	if got, ok := session.Placement(1, 1); !ok || got != placementID {
439		t.Fatalf("failed displayed replacement changed placement: id=%v ok=%v want=%v", got, ok, placementID)
440	}
441	if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 {
442		t.Fatalf("failed displayed replacement usage = %#v", got)
443	}
444}
445
446func TestDeleteSelectedImagePlacementsAndImageNumberNamespace(t *testing.T) {
447	scene := graphics.NewScene(graphics.Limits{})
448	session := NewSession(scene)
449	payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
450	for range 2 {
451		if _, err := session.Feed(apc("a=t,I=7,f=32,s=1,v=1,q=2", payload)); err != nil {
452			t.Fatal(err)
453		}
454	}
455	if got := scene.Usage(); got.Assets != 2 || got.Placements != 0 {
456		t.Fatalf("numbered uploads collapsed namespaces: %#v", got)
457	}
458	if _, err := session.Feed(apc("a=p,I=7,p=2,q=2", "")); err != nil {
459		t.Fatal(err)
460	}
461	if _, err := session.Feed(apc("a=p,i=1,p=1,q=2", "")); err != nil {
462		t.Fatal(err)
463	}
464	if got := scene.Usage(); got.Placements != 2 {
465		t.Fatalf("placements = %#v", got)
466	}
467	if _, err := session.Feed(apc("a=d,d=i,i=1,q=2", "")); err != nil {
468		t.Fatal(err)
469	}
470	if got := scene.Usage(); got.Assets != 2 || got.Placements != 1 {
471		t.Fatalf("selected delete affected unrelated scene state: %#v", got)
472	}
473	if _, err := session.Feed(apc("a=d,d=I,I=7,q=2", "")); err != nil {
474		t.Fatal(err)
475	}
476	if got := scene.Usage(); got.Assets != 1 || got.Placements != 0 {
477		t.Fatalf("image-number delete = %#v", got)
478	}
479	if _, ok := session.Image(1); !ok {
480		t.Fatal("image ID namespace was removed by image-number delete")
481	}
482}
483
484func FuzzParserNeverPanics(f *testing.F) {
485	f.Add([]byte("text\x1b_Ga=q;\x1b\\"))
486	f.Add([]byte("\x1b_Ga=T,m=1;AAAA\x1b\\\x1b_Gm=0;\x1b\\"))
487	f.Fuzz(func(t *testing.T, data []byte) {
488		p := NewParser(Limits{MaxAPCBytes: 1024, MaxPayloadBytes: 512, MaxUploadBytes: 512})
489		_ = p.Feed(data)
490		_ = p.Finish()
491	})
492}
493
494func FuzzSessionNeverPanics(f *testing.F) {
495	f.Add([]byte("\x1b_Ga=t,i=1,f=32,s=1,v=1;AQIDBA==\x1b\\"))
496	f.Add([]byte("\x1b_Ga=T,f=100,m=1;AAAA\x1b\\\x1b_Gm=0;AAAA\x1b\\"))
497	f.Fuzz(func(t *testing.T, data []byte) {
498		s := NewSession(graphics.NewScene(graphics.Limits{}), Limits{
499			MaxAPCBytes: 4096, MaxPayloadBytes: 2048, MaxUploadBytes: 2048,
500			MaxDimension: 128, MaxDecodedPixels: 4096,
501		})
502		_, _ = s.Feed(data)
503		_, _ = s.Finish()
504	})
505}

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 13

↑↓ moveenter openesc close