main f2d166b

capture_workspace_test.go RawHistoryFolder

1package core
2
3import (
4	"context"
5	"testing"
6	"time"
7
8	"github.com/bnema/neferwl/internal/ports"
9)
10
11// hiddenCapture is a 300x200 output whose first workspace (windows 1 and 2,
12// a 100x100 viewport) is off screen, captured.
13func hiddenCapture(t *testing.T) (*Core, *Workspace) {
14	t.Helper()
15	c, ws, _ := hiddenCaptureCommands(t)
16	return c, ws
17}
18
19// hiddenCaptureCommands is hiddenCapture with the channel core sends its commands on.
20func hiddenCaptureCommands(t *testing.T) (*Core, *Workspace, chan ports.ClientCommand) {
21	t.Helper()
22	commands := make(chan ports.ClientCommand, 64)
23	var cfg ports.Config
24	cfg.Keyboard.CmdKey = "super"
25	cfg.Layout.MaxColumns = 2
26	cfg.Border.Width = 2
27	cfg.Background.Color = "#102030"
28	c, err := New(cfg, Channels{Scenes: make(chan []ports.Scene, 1), Commands: commands}, Options{})
29	if err != nil {
30		t.Fatal(err)
31	}
32	c.addScreen(ports.OutputInfo{Name: "A", Width: 300, Height: 200})
33	m := c.screens[0].mon
34	ws := m.Current()
35	ws.SetSize(100, 100)
36	m.AddWindow(1)
37	m.AddWindow(2)
38	m.Focus(1)
39	if m.Current() == ws {
40		t.Fatal("workspace still on screen")
41	}
42	c.configures.cw.load(c.screens[0], ws)
43	return c, ws, commands
44}
45
46func TestCaptureConfigureSizesHiddenWindowWithoutShowingIt(t *testing.T) {
47	c, _ := hiddenCapture(t)
48	sc := c.screens[0]
49	phys := Placement{ID: 1, Hidden: true}
50	t0 := configureTarget{output: "A", view: sc.mon.viewport()}
51	cp, tt := c.captureConfigure(sc, phys, t0)
52	if cp == nil || cp.Hidden {
53		t.Fatalf("no real placement: %+v", cp)
54	}
55	v, send := c.configures.nextWithCapture(phys, tt, cp)
56	want := c.clientRect(*cp)
57	if !send || v.Width != want.W || v.Height != want.H || want.W == 0 {
58		t.Fatalf("configure %+v send %v want size %+v", v, send, want)
59	}
60	if v.Visible || v.Activated || !v.Captured || v.Output != "A" {
61		t.Fatalf("captured hidden window must be sized, unfocused, physically invisible: %+v", v)
62	}
63	c.configures.mark(v)
64	if _, send := c.configures.nextWithCapture(phys, tt, cp); send {
65		t.Fatal("unchanged captured configure sent again")
66	}
67	// The session stops: the window is only hidden again, keeps its size and drops Captured.
68	c.configures.cw.reset()
69	cp, tt = c.captureConfigure(sc, phys, t0)
70	if cp != nil {
71		t.Fatal("placement kept after the session")
72	}
73	v2, send := c.configures.nextWithCapture(phys, tt, cp)
74	if !send || v2.Captured || v2.Visible || v2.Width != v.Width || v2.Height != v.Height {
75		t.Fatalf("released window %+v send %v after %+v", v2, send, v)
76	}
77}
78
79func TestCaptureConfigureLeavesOtherWindowsAlone(t *testing.T) {
80	c, _ := hiddenCapture(t)
81	sc := c.screens[0]
82	phys := Placement{ID: 99, Hidden: true}
83	t0 := configureTarget{output: "A", view: sc.mon.viewport()}
84	if cp, tt := c.captureConfigure(sc, phys, t0); cp != nil || tt != t0 {
85		t.Fatalf("window of another workspace captured: %+v", cp)
86	}
87	v, _ := c.configures.nextWithCapture(phys, t0, nil)
88	if v.Captured || v.Visible || v.Width != 0 {
89		t.Fatalf("plain hidden configure %+v", v)
90	}
91}
92
93func TestCaptureSceneRebasesOnViewport(t *testing.T) {
94	c, ws := hiddenCapture(t)
95	f := ws.Output
96	if f != (Rect{X: 100, Y: 50, W: 100, H: 100}) {
97		t.Fatalf("viewport %+v", f)
98	}
99	c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true}
100	c.popupOrder = append(c.popupOrder, 10)
101	s := c.captureScene(7)
102	if s == nil || s.OutputWidth != 100 || s.OutputHeight != 100 || s.WorkspaceClip != (Rect{W: 100, H: 100}) || s.Seq != 7 || s.Output != "A" {
103		t.Fatalf("scene %+v", s)
104	}
105	if s.Capture != nil || s.CaptureScene != nil || len(s.Layers) != 0 || s.Background != "#102030" || s.Border.Width != 2 {
106		t.Fatalf("capture scene must be flat, layerless and use the normal config: %+v", s)
107	}
108	byID := map[WindowID]ports.SceneWindow{}
109	for _, w := range s.Windows {
110		byID[w.ID] = w
111	}
112	for _, p := range ws.Layout() {
113		w := byID[p.ID]
114		if want := (Rect{X: p.Rect.X - f.X, Y: p.Rect.Y - f.Y, W: p.Rect.W, H: p.Rect.H}); w.Rect != want || w.Focused {
115			t.Fatalf("window %d rect %+v want %+v focused %v", p.ID, w.Rect, want, w.Focused)
116		}
117		if w.Rect.X < 0 || w.Rect.Y < 0 || w.Rect.X+w.Rect.W > 100 || w.Rect.Y+w.Rect.H > 100 {
118			t.Fatalf("window %d outside its viewport: %+v", p.ID, w.Rect)
119		}
120	}
121	client := c.clientRect(ws.Layout()[0])
122	pop := byID[10]
123	if !pop.Popup || pop.Rect != (Rect{X: client.X - f.X + 3, Y: client.Y - f.Y + 4, W: 10, H: 8}) {
124		t.Fatalf("popup %+v client %+v", pop, client)
125	}
126	for _, sep := range s.Separators {
127		if sep.Rect.X < 0 || sep.Rect.Y < 0 || sep.Rect.X+sep.Rect.W > 100 || sep.Rect.Y+sep.Rect.H > 100 {
128			t.Fatalf("separator outside the viewport: %+v", sep)
129		}
130	}
131	c.configures.cw.reset()
132	if c.captureScene(8) != nil {
133		t.Fatal("scene without a captured workspace")
134	}
135}
136
137func TestCapturedPopupsSurviveButHiddenWindowsTakeNoInput(t *testing.T) {
138	c, _ := hiddenCapture(t)
139	c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true}
140	c.popupOrder = append(c.popupOrder, 10)
141	if _, _, ok := c.windowRect(10); ok {
142		t.Fatal("captured popup is physically on screen")
143	}
144	if _, _, ok := c.windowRect(1); ok || c.visible(1) {
145		t.Fatal("captured window is physically on screen")
146	}
147	if id, _, _ := c.hit(150, 100); id != 0 {
148		t.Fatalf("pointer hit hidden window %d", id)
149	}
150	if sc := c.screens[0]; len(c.scenePopups(sc)) != 0 {
151		t.Fatal("captured popup drawn on the physical scene")
152	}
153	if err := c.closeHiddenPopups(context.Background()); err != nil {
154		t.Fatal(err)
155	}
156	if c.popups[10] == nil {
157		t.Fatal("popup of a captured window dismissed")
158	}
159	c.configures.cw.reset()
160	if err := c.closeHiddenPopups(context.Background()); err != nil {
161		t.Fatal(err)
162	}
163	if c.popups[10] != nil {
164		t.Fatal("popup of a hidden window kept after the session")
165	}
166}
167
168func TestCaptureWorkspaceReusesStorage(t *testing.T) {
169	c, ws := hiddenCapture(t)
170	sc := c.screens[0]
171	cw := &c.configures.cw
172	layoutAllocs := testing.AllocsPerRun(50, func() { _ = ws.Layout() })
173	loadAllocs := testing.AllocsPerRun(50, func() { cw.load(sc, ws) })
174	if loadAllocs > layoutAllocs {
175		t.Fatalf("load allocates %v, more than the layout it wraps (%v)", loadAllocs, layoutAllocs)
176	}
177	if got := testing.AllocsPerRun(50, func() {
178		cw.reset()
179		cw.load(sc, ws)
180	}); got > layoutAllocs {
181		t.Fatalf("reset+load allocates %v, layout %v", got, layoutAllocs)
182	}
183}
184
185// While the captured workspace itself slides, the live (Active) session keeps
186// tracking it in its own viewport: its windows stay captured and its popups
187// stay, and both are forgotten when the session ends.
188func TestCapturedWorkspaceStaysCapturedWhileSliding(t *testing.T) {
189	c, ws, _ := hiddenCaptureCommands(t)
190	c.configures.cw.reset()
191	c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}}
192	c.popups[10] = &popupState{id: 10, parent: 1, rect: Rect{X: 3, Y: 4, W: 10, H: 8}, mapped: true}
193	c.popupOrder = append(c.popupOrder, 10)
194	ws.view.motion = newMotion(viewSpring(10, 0), time.Time{}, 1)
195	ctx := context.Background()
196	if err := c.publish(ctx); err != nil {
197		t.Fatal(err)
198	}
199	if v := c.capt.sessions[0].last; !v.Hidden || !v.Active || v.Reason.Terminal() {
200		t.Fatalf("session state %+v", v)
201	}
202	if !c.configures.cw.active() || c.popups[10] == nil {
203		t.Fatal("sliding session lost its captured workspace or popup")
204	}
205	if v, ok := c.configures.sent[1]; !ok || !v.Captured || v.Visible || v.Activated || v.Width <= 0 {
206		t.Fatalf("window during the slide %+v (sent %v)", v, ok)
207	}
208	ws.view.motion = motion{}
209	if err := c.publish(ctx); err != nil {
210		t.Fatal(err)
211	}
212	if !c.configures.cw.active() {
213		t.Fatalf("captured workspace lost after the slide: %+v", c.capt.sessions[0].last)
214	}
215	// The session ends: the captured workspace is forgotten and its popup goes.
216	c.captureClose(1)
217	if err := c.publish(ctx); err != nil {
218		t.Fatal(err)
219	}
220	if c.configures.cw.active() || c.popups[10] != nil {
221		t.Fatal("captured workspace or popup kept after the session")
222	}
223	if v, ok := c.configures.sent[1]; !ok || v.Captured || v.Visible || v.Width <= 0 {
224		t.Fatalf("window after the session %+v (sent %v)", v, ok)
225	}
226}
227
228// In the overview the captured workspace's windows keep their real size and
229// their physical state; nothing is sized as a thumbnail.
230func TestCapturedWorkspaceInOverviewKeepsRealSize(t *testing.T) {
231	c, ws, _ := hiddenCaptureCommands(t)
232	c.configures.cw.reset()
233	m := c.screens[0].mon
234	m.Focus(0)
235	if m.Current() != ws {
236		t.Fatal("workspace not current")
237	}
238	ctx := context.Background()
239	if err := c.publish(ctx); err != nil {
240		t.Fatal(err)
241	}
242	before := map[WindowID]ports.ConfigureWindow{}
243	for _, p := range ws.Layout() {
244		v, ok := c.configures.sent[p.ID]
245		if !ok || v.Width <= 0 || !v.Visible || v.Captured {
246			t.Fatalf("window %d before the overview %+v (sent %v)", p.ID, v, ok)
247		}
248		before[p.ID] = v
249	}
250	m.ToggleOverview()
251	c.capt.sessions = []*capSession{{open: ports.CaptureSessionOpen{ID: 1, Workspace: ws.ID}}}
252	if err := c.publish(ctx); err != nil {
253		t.Fatal(err)
254	}
255	if !c.configures.cw.active() {
256		t.Fatal("overview did not track the captured workspace")
257	}
258	for _, p := range ws.Layout() {
259		v, ok := c.configures.sent[p.ID]
260		if !ok {
261			t.Fatalf("window %d has no configure in the overview", p.ID)
262		}
263		// A preview keeps the client's real size and its physical state; it
264		// is on screen, so it is not Captured.
265		if v.Width != before[p.ID].Width || v.Height != before[p.ID].Height || v.Width != c.clientRect(p).W || v.Height != c.clientRect(p).H {
266			t.Fatalf("window %d configured %dx%d in the overview, real %dx%d", p.ID, v.Width, v.Height, c.clientRect(p).W, c.clientRect(p).H)
267		}
268		if !v.Visible || v.Captured || v.Activated != before[p.ID].Activated {
269			t.Fatalf("window %d shown as a preview but %+v, was %+v", p.ID, v, before[p.ID])
270		}
271	}
272}
273
274// The workspace capture scene carries the corner radius like the live one:
275// a tile with gaps and a float are rounded.
276func TestCaptureSceneRadius(t *testing.T) {
277	c, ws := hiddenCapture(t)
278	c.cfg.Border.Radius = 9
279	ws.Gaps = 4
280	ws.AddFloating(3, 20, 20)
281	c.configures.cw.load(c.screens[0], ws)
282	s := c.captureScene(1)
283	if s == nil || s.Border.Radius != 9 {
284		t.Fatalf("scene %+v", s)
285	}
286	var tiles, floats int
287	for _, w := range s.Windows {
288		if w.Popup {
289			continue
290		}
291		if w.Radius != 9 {
292			t.Fatalf("window %d (floating %v) radius %d, want 9", w.ID, w.Floating, w.Radius)
293		}
294		if w.Floating {
295			floats++
296		} else {
297			tiles++
298		}
299	}
300	if tiles == 0 || floats == 0 {
301		t.Fatalf("tiles %d floats %d", tiles, floats)
302	}
303}

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 25

Tags 10

↑↓ moveenter openesc close