main f2d166b

damage_delta_test.go RawHistoryFolder

1package vulkan
2
3import (
4	"bytes"
5	"image"
6	"image/color"
7	"reflect"
8	"slices"
9	"testing"
10
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/require"
13
14	"github.com/bnema/neferwl/internal/ports"
15)
16
17// TestSceneFieldCount fails when Scene gains or loses a field: sceneDelta
18// lists the fields that force a full redraw.
19func TestSceneFieldCount(t *testing.T) {
20	// 25 with Animating, which sceneDelta intentionally ignores: it only
21	// schedules the next frame, it draws nothing.
22	const fields = 27
23	assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.Scene{}))),
24		"Scene fields changed: check sceneDelta (a field it does not compare must force a full redraw or be covered), then this count")
25}
26
27// TestSceneWindowFieldCount fails when SceneWindow gains or loses a field:
28// sceneDelta compares windows by struct equality, so a new field is covered
29// as long as it only changes where or how that window draws (inside Rect).
30func TestSceneWindowFieldCount(t *testing.T) {
31	const fields = 15
32	assert.Equal(t, fields, len(reflect.VisibleFields(reflect.TypeOf(ports.SceneWindow{}))),
33		"SceneWindow fields changed: check sceneDelta (a field that draws outside Rect needs its own rule), then this count")
34}
35
36func deltaScene(seq uint64, ws ...ports.SceneWindow) ports.Scene {
37	return ports.Scene{Seq: seq, OutputWidth: 1000, OutputHeight: 500, Scale: 1, Background: "#000000", Windows: ws}
38}
39
40func win(id ports.WindowID, x, y, w, h int) ports.SceneWindow {
41	return ports.SceneWindow{ID: id, Rect: ports.Rect{X: x, Y: y, W: w, H: h}}
42}
43
44// held is a target that holds s after a full frame of its windows.
45func held(s ports.Scene) *target {
46	tg := &target{}
47	tg.hold(s, &damageRegion{drawn: map[ports.WindowID]heldWindow{}})
48	return tg
49}
50
51func TestDamageOneWindowResizeIsUnionOfRects(t *testing.T) {
52	bounds := image.Rect(0, 0, 1000, 500)
53	old := deltaScene(1, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 400, 500))
54	cur := deltaScene(2, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 380, 400))
55	d := newDamage(held(old), cur, bounds)
56	require.False(t, d.all())
57	assert.Equal(t, image.Rect(600, 0, 1000, 500), d.area)
58
59	// Window moves: both places.
60	cur = deltaScene(2, win(1, 0, 0, 300, 500), win(2, 310, 0, 300, 500), win(3, 600, 0, 400, 500))
61	d = newDamage(held(old), cur, bounds)
62	require.False(t, d.all())
63	assert.Equal(t, image.Rect(300, 0, 610, 500), d.area)
64}
65
66func TestDamageDeltaAtScale(t *testing.T) {
67	old := deltaScene(1, win(1, 0, 0, 10, 10), win(2, 100, 0, 10, 10))
68	cur := deltaScene(2, win(1, 0, 0, 10, 10), win(2, 100, 0, 20, 10))
69	old.Scale, cur.Scale = 1.5, 1.5
70	d := newDamage(held(old), cur, image.Rect(0, 0, 1500, 750))
71	require.False(t, d.all())
72	assert.Equal(t, image.Rect(150, 0, 180, 15), d.area)
73}
74
75func TestDamageDeltaWindowFieldChange(t *testing.T) {
76	old := deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
77	cur := deltaScene(2, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
78	cur.Windows[0].Dim = 0.3
79	d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
80	require.False(t, d.all())
81	assert.Equal(t, image.Rect(0, 0, 100, 100), d.area)
82}
83
84func TestDamageDeltaFullRedraws(t *testing.T) {
85	base := func() ports.Scene {
86		return deltaScene(1, win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100))
87	}
88	bounds := image.Rect(0, 0, 1000, 500)
89	for name, mod := range map[string]func(*ports.Scene){
90		"camera shift over half": func(s *ports.Scene) {
91			s.Windows = []ports.SceneWindow{win(1, 0, 0, 600, 500), win(2, 600, 0, 400, 500)}
92			s.Windows[0].Rect.X = 50
93		},
94		"layer":          func(s *ports.Scene) { s.Layers = []ports.SceneLayer{{ID: 9, Rect: ports.Rect{W: 10, H: 10}}} },
95		"output":         func(s *ports.Scene) { s.Output = "DP-2" },
96		"size":           func(s *ports.Scene) { s.OutputWidth = 999 },
97		"scale":          func(s *ports.Scene) { s.Scale = 2 },
98		"transform":      func(s *ports.Scene) { s.Transform = 1 },
99		"off":            func(s *ports.Scene) { s.Off = true },
100		"background":     func(s *ports.Scene) { s.Background = "#111111" },
101		"border":         func(s *ports.Scene) { s.Border.Width = 2 },
102		"workspace clip": func(s *ports.Scene) { s.WorkspaceClip = ports.Rect{W: 10, H: 10} },
103		"tile inset":     func(s *ports.Scene) { s.TileInset = ports.Insets{Bottom: 10} },
104		"dim":            func(s *ports.Scene) { s.Dim = 0.5 },
105		"dim behind":     func(s *ports.Scene) { s.DimBehind = true },
106		"drop hints":     func(s *ports.Scene) { s.DropHints = []ports.Rect{{W: 5, H: 5}} },
107		"indicators":     func(s *ports.Scene) { s.CaptureIndicators = []ports.CaptureIndicator{{Rect: ports.Rect{W: 5, H: 5}}} },
108		"capture":        func(s *ports.Scene) { s.Capture = &ports.SceneCapture{} },
109		"capture scene":  func(s *ports.Scene) { s.CaptureScene = &ports.Scene{} },
110		"security":       func(s *ports.Scene) { s.Security.Protected = true },
111		"window count":   func(s *ports.Scene) { s.Windows = s.Windows[:1] },
112		"window order":   func(s *ports.Scene) { s.Windows[0], s.Windows[1] = s.Windows[1], s.Windows[0] },
113		"window id":      func(s *ports.Scene) { s.Windows[1].ID = 7 },
114	} {
115		t.Run(name, func(t *testing.T) {
116			cur := base()
117			mod(&cur)
118			cur.Seq = 2
119			assert.True(t, newDamage(held(base()), cur, bounds).all())
120		})
121	}
122	t.Run("seq 0 and cold target", func(t *testing.T) {
123		cur := base()
124		cur.Seq = 0
125		assert.True(t, newDamage(held(base()), cur, bounds).all())
126		cur.Seq = 2
127		assert.True(t, newDamage(&target{}, cur, bounds).all())
128	})
129}
130
131func TestDamageDeltaSeparators(t *testing.T) {
132	sep := func(x int) ports.Separator { return ports.Separator{Rect: ports.Rect{X: x, W: 2, H: 500}} }
133	a, b := sep(100), sep(200)
134	old := deltaScene(1, win(1, 0, 0, 500, 500))
135	old.Separators = []ports.Separator{a, b}
136	cur := deltaScene(2, win(1, 0, 0, 500, 500))
137	cur.Separators = []ports.Separator{a, sep(250)}
138	d := newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
139	require.False(t, d.all())
140	assert.Equal(t, image.Rect(200, 0, 252, 500), d.area)
141
142	// Same lines, another order: nothing differs by value.
143	cur.Separators = []ports.Separator{b, a}
144	d = newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
145	require.False(t, d.all())
146	assert.True(t, d.area.Empty())
147
148	// A line that turns active is a different value.
149	cur.Separators = []ports.Separator{a, {Rect: b.Rect, Active: true}}
150	d = newDamage(held(old), cur, image.Rect(0, 0, 1000, 500))
151	require.False(t, d.all())
152	assert.Equal(t, image.Rect(200, 0, 202, 500), d.area)
153}
154
155func TestSceneDeltaAllocations(t *testing.T) {
156	// Many separators (twenty tiles' worth): the same-length lists are
157	// compared index by index, and a list that lost a line still allocates
158	// nothing.
159	const seps = 80
160	old := deltaScene(1, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 400, 500))
161	cur := deltaScene(2, win(1, 0, 0, 300, 500), win(2, 300, 0, 300, 500), win(3, 600, 0, 380, 500))
162	for i := range seps {
163		s := ports.Separator{Rect: ports.Rect{X: i * 10, W: 2, H: 500}}
164		old.Separators = append(old.Separators, s)
165		cur.Separators = append(cur.Separators, s)
166	}
167	cur.Separators[5].Active = true
168	phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
169	var area image.Rectangle
170	var ok bool
171	allocs := testing.AllocsPerRun(50, func() { area, ok = sceneDelta(old, cur, phys, 1<<30) })
172	require.True(t, ok)
173	assert.False(t, area.Empty())
174	assert.True(t, image.Rect(50, 0, 52, 500).In(area), "the changed line is damaged: %v", area)
175	assert.Zero(t, allocs, "sceneDelta must not allocate")
176
177	short := cur
178	short.Separators = cur.Separators[:seps-1]
179	allocs = testing.AllocsPerRun(50, func() { area, ok = sceneDelta(old, short, phys, 1<<30) })
180	require.True(t, ok)
181	assert.True(t, image.Rect((seps-1)*10, 0, (seps-1)*10+2, 500).In(area), "the dropped line is damaged: %v", area)
182	assert.Zero(t, allocs, "sceneDelta must not allocate with lists of different lengths")
183}
184
185// Separators are damaged where a line is in one list more often than in
186// the other, whatever the order: equal lists add nothing, a changed pair
187// adds both rects, a rotated list adds nothing, a duplicated line that
188// replaces another adds both, a line added or dropped adds that line.
189func TestSceneDeltaSeparators(t *testing.T) {
190	phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
191	line := func(x int) ports.Separator { return ports.Separator{Rect: ports.Rect{X: x, W: 2, H: 10}} }
192	a, b, c := line(0), line(100), line(200)
193	assert.True(t, separatorDelta([]ports.Separator{a, b}, []ports.Separator{a, b}, phys).Empty())
194	assert.Equal(t, image.Rect(100, 0, 202, 10), separatorDelta([]ports.Separator{a, b}, []ports.Separator{a, c}, phys))
195	assert.True(t, separatorDelta([]ports.Separator{a, b, c}, []ports.Separator{b, c, a}, phys).Empty())
196	assert.Equal(t, image.Rect(0, 0, 102, 10), separatorDelta([]ports.Separator{a, b, c}, []ports.Separator{a, a, c}, phys))
197	assert.Equal(t, image.Rect(200, 0, 202, 10), separatorDelta([]ports.Separator{a, b}, []ports.Separator{a, b, c}, phys))
198	assert.Equal(t, image.Rect(0, 0, 2, 10), separatorDelta([]ports.Separator{a, b, c}, []ports.Separator{b, c}, phys))
199}
200
201// The paint order splits at the first window that opens the floats: when
202// that window changes, the whole output is redrawn.
203func TestSceneDeltaFloatsStartMoves(t *testing.T) {
204	phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
205	tile := func(id ports.WindowID, x int) ports.SceneWindow { return win(id, x, 0, 100, 100) }
206	float := func(id ports.WindowID, x int) ports.SceneWindow {
207		w := win(id, x, 0, 100, 100)
208		w.Floating = true
209		return w
210	}
211	delta := func(a, b []ports.SceneWindow) bool {
212		_, ok := sceneDelta(deltaScene(1, a...), deltaScene(2, b...), phys, 1<<30)
213		return ok
214	}
215	// A tile becomes a float: the tile lines and the veil move under it.
216	assert.False(t, delta([]ports.SceneWindow{tile(1, 0), tile(2, 100)}, []ports.SceneWindow{tile(1, 0), float(2, 100)}))
217	// The first float moves in the list.
218	assert.False(t, delta([]ports.SceneWindow{float(1, 0), float(2, 100)}, []ports.SceneWindow{tile(1, 0), float(2, 100)}))
219	// A hidden or popup float opens nothing; showing it does.
220	hidden := float(2, 100)
221	hidden.Hidden = true
222	assert.False(t, delta([]ports.SceneWindow{tile(1, 0), hidden}, []ports.SceneWindow{tile(1, 0), float(2, 100)}))
223	popup := float(2, 100)
224	popup.Popup = true
225	assert.False(t, delta([]ports.SceneWindow{tile(1, 0), popup}, []ports.SceneWindow{tile(1, 0), float(2, 100)}))
226	// A below float or an overview preview does not open the floats.
227	below := float(2, 100)
228	below.Below = true
229	assert.True(t, delta([]ports.SceneWindow{tile(1, 0), tile(2, 100)}, []ports.SceneWindow{tile(1, 0), below}))
230	preview := float(2, 100)
231	preview.Preview = 0.5
232	assert.True(t, delta([]ports.SceneWindow{tile(1, 0), tile(2, 100)}, []ports.SceneWindow{tile(1, 0), preview}))
233	// The same first float with another rect stays a delta.
234	assert.True(t, delta([]ports.SceneWindow{tile(1, 0), float(2, 100)}, []ports.SceneWindow{tile(1, 0), float(2, 130)}))
235}
236
237// The target keeps its own copy of the compared slices: a caller reusing
238// its slices in place (DRM overlay frames, capture) must not change what the
239// target holds.
240func TestTargetHoldCopiesSlices(t *testing.T) {
241	ws := []ports.SceneWindow{win(1, 0, 0, 100, 100), win(2, 100, 0, 100, 100)}
242	seps := []ports.Separator{{Rect: ports.Rect{X: 100, W: 2, H: 100}}}
243	layers := []ports.SceneLayer{{ID: 9, Rect: ports.Rect{W: 10, H: 10}}}
244	hints := []ports.Rect{{W: 5, H: 5}}
245	marks := []ports.CaptureIndicator{{Rect: ports.Rect{W: 5, H: 5}}}
246	s := deltaScene(1)
247	s.Windows, s.Separators, s.Layers, s.DropHints, s.CaptureIndicators = ws, seps, layers, hints, marks
248	tg := &target{}
249	d := &damageRegion{drawn: map[ports.WindowID]heldWindow{}}
250	tg.hold(s, d)
251	ws[1].Rect.X, seps[0].Rect.X, layers[0].ID, hints[0].W, marks[0].Rect.W = 150, 150, 8, 6, 6
252	phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }
253	// Compared with the mutated slices as the new scene, the held copy
254	// differs in each; with a held reference nothing would differ.
255	cur := s
256	cur.Seq = 2
257	_, ok := sceneDelta(tg.scene, cur, phys, 1<<30)
258	assert.False(t, ok, "layers, hints and indicators changed: full redraw")
259	cur.Layers, cur.DropHints, cur.CaptureIndicators = tg.heldLayers, tg.heldHints, tg.heldIndicators
260	area, ok := sceneDelta(tg.scene, cur, phys, 1<<30)
261	require.True(t, ok)
262	assert.Equal(t, image.Rect(100, 0, 250, 100), area, "window 2, old and new")
263	// Zero allocations once the buffers are warm.
264	allocs := testing.AllocsPerRun(50, func() { tg.hold(s, d) })
265	assert.Zero(t, allocs, "hold must not allocate once warm")
266}
267
268// deltaCase is a frame sequence of two windows whose second one resizes.
269type deltaCase struct {
270	name      string
271	scale     float64
272	out       ports.BufferTransform
273	sceneDim  float64
274	floating  bool // window 2 is a float: the veil and tile lines go under it
275	windowDim bool // window 1 is dimmed on some frames
276}
277
278func (c deltaCase) scene(seq uint64, i, w2 int, windows []ports.SceneWindow, seps []ports.Separator) ports.Scene {
279	windows[0], windows[1] = win(1, 0, 0, 20, 48), win(2, 20, 0, w2, 48)
280	windows[1].Floating = c.floating
281	if c.windowDim && i%3 == 0 {
282		windows[0].Dim = 0.5
283	}
284	seps[0] = ports.Separator{Rect: ports.Rect{X: 20, W: 1, H: 48}}
285	return ports.Scene{Seq: seq, Transform: c.out, OutputWidth: 64, OutputHeight: 48, Scale: c.scale, Background: "#101010",
286		Dim: c.sceneDim, Windows: windows, Separators: seps}
287}
288
289func deltaCases() []deltaCase {
290	return []deltaCase{
291		{name: "scale 1", scale: 1},
292		{name: "scale 1.5", scale: 1.5},
293		{name: "transform 90", scale: 1, out: 1},
294		{name: "scene dim over tiles", scale: 1, sceneDim: 0.4, floating: true},
295		{name: "window dim", scale: 1, windowDim: true},
296		{name: "scale 1.5 rotated, all", scale: 1.5, out: 1, sceneDim: 0.3, floating: true, windowDim: true},
297	}
298}
299
300// A partial redraw after a window resize is pixel-identical to a full
301// redraw, on both targets of a double-buffered output. With reuse the scene
302// slices are rewritten in place for every frame, like an output adapter
303// that reuses them: the target must not have kept a reference.
304func TestRendererSceneDeltaMatchesFullRedraw(t *testing.T) {
305	for _, reuse := range []bool{false, true} {
306		for _, tc := range deltaCases() {
307			name := tc.name
308			if reuse {
309				name += " reused slices"
310			}
311			t.Run(name, func(t *testing.T) { sceneDeltaMatchesFullRedraw(t, tc, reuse) })
312		}
313	}
314}
315
316func sceneDeltaMatchesFullRedraw(t *testing.T, tc deltaCase, reuse bool) {
317	tw, th := tc.out.Size(int(64*tc.scale+0.5), int(48*tc.scale+0.5))
318	newR := func() *Renderer {
319		r, err := New(tw, th)
320		if err != nil {
321			t.Skipf("Vulkan unavailable: %v", err)
322		}
323		t.Cleanup(r.Close)
324		return r
325	}
326	damaged, full := newR(), newR()
327	bufs, err := damaged.ExportTargets(2, nil, false)
328	if err != nil {
329		t.Skipf("no exportable targets: %v", err)
330	}
331	for _, b := range bufs {
332		for _, p := range b.Planes {
333			p.File.Close()
334		}
335	}
336	red := solidContent(t, 8, 8, color.RGBA{R: 255, A: 255})
337	red.ID, red.Surface, red.Seq, red.Version = 1, 1, 1, 1
338	green := solidContent(t, 8, 8, color.RGBA{G: 255, A: 255})
339	green.ID, green.Surface, green.Seq, green.Version = 2, 2, 1, 1
340	contents := map[ports.WindowID]ports.SurfaceContent{1: red, 2: green}
341	shared := make([]ports.SceneWindow, 2)
342	sharedSeps := make([]ports.Separator, 1)
343	var partial int
344	for i, w2 := range []int{20, 20, 30, 24, 24, 40, 20, 22} {
345		windows, seps := shared, sharedSeps
346		if !reuse {
347			windows, seps = make([]ports.SceneWindow, 2), make([]ports.Separator, 1)
348		}
349		if reuse {
350			clear(windows)
351		}
352		s := tc.scene(uint64(10+i), i, w2, windows, seps)
353		damaged.UseTarget(i % 2)
354		before := damaged.redrawn
355		require.NoError(t, render(damaged, s, contents))
356		if damaged.redrawn-before < tw*th {
357			partial++
358		}
359		plain := s
360		plain.Seq = 0
361		plain.Windows = slices.Clone(s.Windows)
362		plain.Separators = slices.Clone(s.Separators)
363		require.NoError(t, render(full, plain, contents))
364		a, b := readPixels(t, damaged), readPixels(t, full)
365		if !bytes.Equal(a.Pix, b.Pix) {
366			t.Fatalf("frame %d (w2=%d) differs from a full redraw", i, w2)
367		}
368	}
369	assert.Positive(t, partial, "no frame was redrawn partially")
370}
371
372// A window's Fade changes only how it draws inside its rect: the delta is
373// that rect. A leaving entry (core keeps a closed or hidden window in the
374// scene while it fades) changes the window list when it appears and when
375// it goes: both redraw everything, like any list change; while it fades,
376// only its rect is redrawn.
377func TestDamageDeltaFadeAndLeavingEntry(t *testing.T) {
378	bounds := image.Rect(0, 0, 1000, 500)
379	settled := deltaScene(1, win(1, 0, 0, 500, 500), win(2, 500, 0, 500, 500))
380
381	faded := deltaScene(2, win(1, 0, 0, 500, 500), win(2, 500, 0, 500, 500))
382	faded.Windows[1].Fade = 0.5
383	d := newDamage(held(settled), faded, bounds)
384	require.False(t, d.all())
385	assert.Equal(t, image.Rect(500, 0, 1000, 500), d.area)
386
387	// Window 2 was unmapped: the layout re-flows and a leaving entry is
388	// appended. The list changed: full redraw.
389	leaving := deltaScene(3, win(1, 0, 0, 1000, 500), win(2, 500, 0, 500, 500))
390	leaving.Windows[1].Fade = 0.1
391	assert.True(t, newDamage(held(settled), leaving, bounds).all())
392
393	// The fade advances: its rect only, the neighbours untouched.
394	next := deltaScene(4, win(1, 0, 0, 1000, 500), win(2, 500, 0, 500, 500))
395	next.Windows[1].Fade = 0.6
396	d = newDamage(held(leaving), next, bounds)
397	require.False(t, d.all())
398	assert.Equal(t, image.Rect(500, 0, 1000, 500), d.area)
399
400	// The entry settled and is dropped: full redraw (its pixels are stale).
401	gone := deltaScene(5, win(1, 0, 0, 1000, 500))
402	assert.True(t, newDamage(held(next), gone, bounds).all())
403
404	// A leaving float opens the floats where it is appended: the tile
405	// lines and the veil move under it, so floatsStart differs.
406	float := deltaScene(6, win(1, 0, 0, 1000, 500), win(2, 100, 100, 200, 200))
407	float.Windows[1].Floating, float.Windows[1].Fade = true, 0.1
408	assert.True(t, newDamage(held(gone), float, bounds).all())
409}
410
411func TestSceneDeltaFlashRectDamage(t *testing.T) {
412	a := &ports.FocusFlash{Rect: ports.Rect{X: 10, Y: 20, W: 100, H: 80}, Width: 3, Color: "#ff0000", Alpha: 1}
413	faded := *a
414	faded.Alpha = 0.5
415	moved := faded
416	moved.Rect.X = 150
417	phys := func(r ports.Rect) image.Rectangle { return image.Rect(r.X*2, r.Y*2, (r.X+r.W)*2, (r.Y+r.H)*2) }
418	for _, tc := range []struct {
419		name     string
420		old, cur *ports.FocusFlash
421		want     image.Rectangle
422	}{
423		{"appears", nil, a, phys(a.Rect)},
424		{"alpha", a, &faded, phys(a.Rect)},
425		{"moves", a, &moved, phys(a.Rect).Union(phys(moved.Rect))},
426		{"removed", a, nil, phys(a.Rect)},
427		{"idle identical", a, a, image.Rectangle{}},
428	} {
429		t.Run(tc.name, func(t *testing.T) {
430			old, cur := deltaScene(1), deltaScene(2)
431			old.Flash, cur.Flash = tc.old, tc.cur
432			area, ok := sceneDelta(old, cur, phys, 1000000)
433			require.True(t, ok)
434			assert.Equal(t, tc.want, area)
435		})
436	}
437	old, cur := deltaScene(1), deltaScene(2)
438	cur.Flash = a
439	_, ok := sceneDelta(old, cur, phys, 1)
440	require.False(t, ok, "large damage must retain full-redraw fallback")
441}
442
443func TestDamageSelectionOutlineDelta(t *testing.T) {
444	a := &ports.SelectionOutline{Rect: ports.Rect{X: 10, Y: 10, W: 40, H: 30}, Width: 2, Radius: 10, Active: true}
445	b := *a
446	b.Rect.X = 60
447	wider, rounder := *a, *a
448	wider.Width++
449	rounder.Radius++
450	for _, pair := range [][2]*ports.SelectionOutline{{nil, a}, {a, nil}, {a, &b}, {a, &wider}, {a, &rounder}, {a, a}} {
451		old, cur := deltaScene(1), deltaScene(2)
452		old.Outline, cur.Outline = pair[0], pair[1]
453		area, ok := sceneDelta(old, cur, func(r ports.Rect) image.Rectangle { return image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H) }, 500000)
454		require.True(t, ok)
455		var want image.Rectangle
456		if pair[0] != pair[1] {
457			for _, o := range pair {
458				if o != nil {
459					r := o.Rect
460					want = want.Union(image.Rect(r.X, r.Y, r.X+r.W, r.Y+r.H))
461				}
462			}
463		}
464		require.Equal(t, want, area)
465	}
466}

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 26

Tags 10

↑↓ moveenter openesc close