v0.6.1 aa12db3

interaction_test.go RawHistoryFolder

1package ui
2
3import (
4	"encoding/json"
5	"strings"
6	"testing"
7
8	"github.com/bnema/nefergui/internal/css"
9	"github.com/bnema/nefergui/internal/text"
10)
11
12func key(name string) platformInput {
13	return platformInput{Kind: "key", Key: keyEvent{Name: name, Pressed: true}}
14}
15func shell(r *runtime, activated *int) func(*Frame) {
16	return func(f *Frame) {
17		root := f.Root()
18		root.Text("Hello")
19		if root.Button("Go", Key("go")).Activated() {
20			*activated++
21		}
22		root.Button("Disabled", Key("off"), Disabled(true))
23		root.Button("Next", Key("next"))
24	}
25}
26func TestShellStyleAndFocus(t *testing.T) {
27	r := newRuntime()
28	catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
29	if err != nil {
30		t.Fatal(err)
31	}
32	r.setTextEngine(text.NewEngine(catalog))
33	r.styles = css.Compile(css.UA(), css.Parse(`text {height:12px;color:red} button {height:20px;background:blue} button:hover {background:green} button:focus-visible {outline:2px solid red}`))
34	var activated int
35	view := shell(r, &activated)
36	if !r.Build(view) {
37		t.Fatal("first frame")
38	}
39	if len(r.output.Tree.Children) != 4 || len(r.output.Tree.Children[0].Lines) == 0 {
40		t.Fatal("text shell was not shaped and laid out", r.output.Tree)
41	}
42	button := r.output.Tree.Children[1]
43	r.route(platformInput{Kind: "motion", X: 2, Y: button.Border.Y + 2})
44	r.Build(view)
45	data, err := json.Marshal(r.output.Display)
46	if err != nil || !strings.Contains(string(data), `"G":0.5019607843137255`) {
47		t.Fatalf("hover snapshot: %s %v", data, err)
48	}
49	r.route(key("Tab"))
50	r.Build(view)
51	data, err = json.Marshal(r.output.Display)
52	if err != nil || !strings.Contains(string(data), `"Op":"outline"`) {
53		t.Fatalf("focus-visible snapshot: %s %v", data, err)
54	}
55	if !r.state.focus.same(r.Target(1)) || !r.state.visible {
56		t.Fatal("tab focus")
57	}
58	r.route(key("Return"))
59	r.Build(view)
60	if activated != 1 {
61		t.Fatal("keyboard activation", activated)
62	}
63	r.route(key("Tab"))
64	r.Build(view)
65	if !r.state.focus.same(r.Target(3)) {
66		t.Fatal("disabled included in tab order")
67	}
68	r.route(platformInput{Kind: "key", Shift: true, Key: keyEvent{Name: "Tab", Pressed: true}})
69	r.Build(view)
70	if !r.state.focus.same(r.Target(1)) {
71		t.Fatal("reverse tab")
72	}
73	r.route(platformInput{Kind: "press", Button: pointerPrimary, X: 2, Y: button.Border.Y + 2})
74	r.Build(view)
75	if r.state.visible {
76		t.Fatal("pointer focus visible")
77	}
78	r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 2, Y: button.Border.Y + 2})
79	r.Build(view)
80	if activated != 2 {
81		t.Fatal("pointer activation", activated)
82	}
83}
84func TestOverlapCaptureRemovalAndScale(t *testing.T) {
85	r := newRuntime()
86	r.styles = css.Compile(css.UA(), css.Parse(`stack {height:30px} button {width:40px;height:20px}`))
87	front := true
88	var back, top int
89	view := func(f *Frame) {
90		s := f.Root().Stack()
91		if s.Button("back", Key("back")).Activated() {
92			back++
93		}
94		if front {
95			if s.Button("front", Key("front")).Activated() {
96				top++
97			}
98		}
99	}
100	r.Build(view)
101	for _, scale := range []float64{1.25, 1.5, 2} {
102		r.route(platformInput{Kind: "resize", Width: 80, Height: 60, Scale: scale})
103		r.Build(view)
104		if got := r.hit(20, 10); got == nil || got.identity.key != "front" {
105			t.Fatalf("scale %v hit %+v", scale, got)
106		}
107		r.route(platformInput{Kind: "press", Button: pointerPrimary, X: 20, Y: 10})
108		r.Build(view)
109		if !r.state.capture.same(r.Target(0, 1)) {
110			t.Fatal("capture")
111		}
112		r.route(platformInput{Kind: "motion", X: 70, Y: 50})
113		r.Build(view)
114		if !r.state.capture.same(r.Target(0, 1)) {
115			t.Fatal("capture lost outside")
116		}
117		r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 70, Y: 50})
118		r.Build(view)
119		if top != 0 || back != 0 {
120			t.Fatal("outside release activated")
121		}
122	}
123	r.route(platformInput{Kind: "press", Button: pointerPrimary, X: 20, Y: 10})
124	r.Build(view)
125	front = false
126	r.Redraw()
127	r.Build(view)
128	if r.state.capture != nil || r.state.down {
129		t.Fatal("removed capture retained")
130	}
131	r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 20, Y: 10})
132	r.Build(view)
133	if top != 0 || back != 0 {
134		t.Fatal("removed target activated")
135	}
136}
137func TestFocusRemovalAndScrollClamp(t *testing.T) {
138	r := newRuntime()
139	r.styles = css.Compile(css.UA(), css.Parse(`scroll {width:40px;height:20px} button {height:30px}`))
140	show := true
141	view := func(f *Frame) {
142		root := f.Root()
143		s := root.Scroll(Key("sc"))
144		if show {
145			s.Button("one", Key("one"))
146		}
147		s.Button("two", Key("two"))
148	}
149	r.Build(view)
150	r.route(key("Tab"))
151	r.Build(view)
152	if !r.state.focus.same(r.Target(0, 0)) {
153		t.Fatal("initial focus")
154	}
155	show = false
156	r.Redraw()
157	r.Build(view)
158	if r.state.focus != nil {
159		t.Fatal("focus should fall back to none")
160	}
161	r.route(key("Tab"))
162	r.Build(view)
163	if !r.state.focus.same(r.Target(0, 0)) {
164		t.Fatal("tab after removal")
165	}
166	r.route(platformInput{Kind: "axis", X: 1, Y: 1, DY: 1000})
167	r.Build(view)
168	n := resultByID(r.output.Tree, idKey(r.Target(0)))
169	if n == nil || n.ScrollY != 10 {
170		t.Fatalf("scroll clamp: %+v", n)
171	}
172	if r.Build(view) {
173		t.Fatal("idle redraw")
174	}
175	r.route(platformInput{Kind: "axis", X: 1, Y: 1, DY: 1000})
176	if r.Build(view) {
177		t.Fatal("clamped wheel redraw")
178	}
179	r.route(platformInput{Kind: "axis", X: 1, Y: 1, DY: -1000})
180	r.Build(view)
181	if r.output.Tree.Children[0].ScrollY != 0 {
182		t.Fatal("negative scroll clamp")
183	}
184}
185
186func TestPointerButtonOwnership(t *testing.T) {
187	r := newRuntime()
188	r.styles = css.Compile(css.UA(), css.Parse(`button {height:20px}`))
189	a, b := 0, 0
190	view := func(f *Frame) {
191		root := f.Root()
192		if root.Button("a", Key("a")).Activated() {
193			a++
194		}
195		if root.Button("b", Key("b")).Activated() {
196			b++
197		}
198	}
199	r.Build(view)
200	pointer := func(kind string, button uint32, y float64) {
201		r.route(platformInput{Kind: kind, Button: button, X: 2, Y: y})
202	}
203	const secondary uint32 = 0x111
204	pointer("press", pointerPrimary, 2)
205	r.Build(view)
206	pointer("press", secondary, 22)
207	if !r.state.capture.same(r.Target(0)) || !r.state.focus.same(r.Target(0)) {
208		t.Fatal("second press stole capture/focus")
209	}
210	pointer("release", secondary, 22)
211	if !r.state.down {
212		t.Fatal("second release ended capture")
213	}
214	pointer("release", pointerPrimary, 2)
215	r.Build(view)
216	if a != 1 || b != 0 || r.state.down {
217		t.Fatalf("primary completion: %d %d", a, b)
218	}
219	pointer("press", secondary, 22)
220	r.Build(view)
221	if !r.state.capture.same(r.Target(1)) || r.state.focus == nil || !r.state.focus.same(r.Target(0)) {
222		t.Fatal("secondary capture changed focus")
223	}
224	pointer("release", pointerPrimary, 22)
225	if !r.state.down {
226		t.Fatal("wrong release ended secondary capture")
227	}
228	pointer("release", secondary, 22)
229	r.Build(view)
230	if b != 0 || r.state.down {
231		t.Fatal("secondary activated or remained captured")
232	}
233}
234
235// The cursor follows the hovered element's CSS cursor, stays on the captured
236// element while a primary button is held, and resets outside the surface.
237func TestCursorFollowsHoverAndCapture(t *testing.T) {
238	r := newRuntime()
239	r.styles = css.Compile(css.UA(), css.Parse(`button,input{height:20px;width:100px}`))
240	name := ""
241	view := func(f *Frame) {
242		root := f.Root()
243		root.Button("go", Key("go"))
244		root.Input("name", &name, Key("name"))
245		root.Button("off", Key("off"), Disabled(true))
246		root.Text("plain", Key("plain"))
247	}
248	r.Build(view)
249	at := func(kind string, y float64) css.Keyword {
250		r.route(platformInput{Kind: kind, Button: pointerPrimary, X: 2, Y: y})
251		return r.cursor()
252	}
253	if c := r.cursor(); c != css.KeywordDefault {
254		t.Fatalf("outside: %v", c)
255	}
256	for _, c := range []struct {
257		y    float64
258		want Cursor
259	}{{2, CursorPointer}, {22, CursorText}, {42, CursorNotAllowed}, {62, CursorDefault}} {
260		if got := cursorOf(at("motion", c.y)); got != c.want {
261			t.Errorf("y=%v shape %v, want %v", c.y, got, c.want)
262		}
263	}
264	if cursorOf(css.KeywordAuto) != cursorOf(css.KeywordDefault) {
265		t.Error("auto must map to the default shape")
266	}
267	// Press on the input, drag over the button: the input keeps its cursor.
268	at("press", 22)
269	r.Build(view)
270	if got := at("motion", 2); got != css.KeywordText {
271		t.Errorf("captured drag cursor %v", got)
272	}
273	at("release", 2)
274	r.Build(view)
275	if got := r.cursor(); got != css.KeywordPointer {
276		t.Errorf("after release cursor %v", got)
277	}
278	r.route(platformInput{Kind: "leave"})
279	if got := r.cursor(); got != css.KeywordDefault {
280		t.Errorf("after leave cursor %v", got)
281	}
282}
283
284// A control declared before the one whose event changes the model painted the
285// old value during the event frame. The settle frame must show the new value
286// without further input (the demo menu kept its old active item until the
287// pointer moved).
288func TestEventSettlesEarlierControls(t *testing.T) {
289	r := newRuntime()
290	r.styles = css.Compile(css.UA(), css.Parse(`button{height:20px} button.active{background:#ff0000}`))
291	page := "a"
292	view := func(f *Frame) {
293		root := f.Root()
294		for _, p := range []string{"a", "b"} {
295			opts := []ButtonOption{Key(p)}
296			if page == p {
297				opts = append(opts, Class("active"))
298			}
299			if root.Button(p, opts...).Activated() {
300				page = p
301			}
302		}
303	}
304	active := func() string {
305		for _, c := range r.output.Display {
306			if c.Op == "rect" && c.Color.R == 1 && c.Color.G == 0 {
307				return c.ID
308			}
309		}
310		return ""
311	}
312	r.Build(view)
313	r.route(platformInput{Kind: "press", Button: pointerPrimary, X: 2, Y: 22})
314	r.Build(view)
315	r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 2, Y: 22})
316	r.Build(view)
317	if page != "b" {
318		t.Fatalf("page %q", page)
319	}
320	if !r.Build(view) {
321		t.Fatal("no settle frame after activation")
322	}
323	if got := active(); got != "root/k1:b" {
324		t.Fatalf("active item painted as %q after settle", got)
325	}
326	if r.Build(view) {
327		t.Fatal("settle frame scheduled another frame")
328	}
329}
330
331func TestKeyboardActivationLifecycle(t *testing.T) {
332	r := newRuntime()
333	r.styles = css.Compile(css.UA(), css.Parse(`button {height:20px} button:active {background:red}`))
334	count := 0
335	view := func(f *Frame) {
336		root := f.Root()
337		if root.Button("go", Key("go")).Activated() {
338			count++
339		}
340		root.Button("other", Key("other"))
341	}
342	r.Build(view)
343	r.route(key("Tab"))
344	r.Build(view)
345	repeat := key("Return")
346	repeat.Key.Repeat = true
347	r.route(key("Return"))
348	r.route(repeat)
349	r.Build(view)
350	if count != 1 {
351		t.Fatal("Enter repeat activated", count)
352	}
353	space := key("space")
354	spaceRepeat := space
355	spaceRepeat.Key.Repeat = true
356	r.route(space)
357	r.route(spaceRepeat)
358	r.Build(view)
359	if count != 1 || r.state.space == nil || r.state.flags(r.committed.children[0])&css.Active == 0 {
360		t.Fatal("space press should only set active")
361	}
362	r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
363	r.Build(view)
364	if count != 2 || r.state.space != nil {
365		t.Fatal("space release should activate once", count)
366	}
367	r.route(space)
368	r.route(key("Escape"))
369	r.Build(view)
370	r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
371	if r.Build(view) {
372		t.Fatal("cancelled release redrew")
373	}
374	if count != 2 {
375		t.Fatal("Escape failed to cancel")
376	}
377	r.route(space)
378	r.route(platformInput{Kind: "focus-out"})
379	r.Build(view)
380	r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
381	if r.Build(view) || count != 2 {
382		t.Fatal("focus loss failed to cancel")
383	}
384	r.route(key("Tab"))
385	r.Build(view)
386	r.route(space)
387	r.route(key("Tab"))
388	r.Build(view)
389	r.route(platformInput{Kind: "key", Key: keyEvent{Name: "space"}})
390	if r.Build(view) || count != 2 {
391		t.Fatal("focus change failed to cancel")
392	}
393}
394
395func TestScrollIdentityStopsBeingScrollable(t *testing.T) {
396	r := newRuntime()
397	r.styles = css.Compile(css.UA(), css.Parse(`scroll, box {width:40px;height:20px} text {height:40px}`))
398	scroll := true
399	view := func(f *Frame) {
400		root := f.Root()
401		var n Node
402		if scroll {
403			n = root.Scroll(Key("same"))
404		} else {
405			n = root.Box(Key("same"))
406		}
407		n.Text("content")
408	}
409	r.Build(view)
410	r.route(platformInput{Kind: "axis", X: 1, Y: 1, DY: 15})
411	r.Build(view)
412	id := idKey(r.Target(0))
413	if r.state.scroll[id].H != 15 {
414		t.Fatal("scroll not stored")
415	}
416	scroll = false
417	r.Redraw()
418	r.Build(view)
419	if _, ok := r.state.scroll[id]; ok {
420		t.Fatal("non-scroll container retained offset")
421	}
422	scroll = true
423	r.Redraw()
424	r.Build(view)
425	if r.output.Tree.Children[0].ScrollY != 0 {
426		t.Fatal("stale offset restored")
427	}
428}
429
430func TestKeyedReorderKeepsInteractionState(t *testing.T) {
431	r := newRuntime()
432	r.styles = css.Compile(css.UA(), css.Parse(`scroll {height:20px} button {height:30px}`))
433	order := []string{"a", "b"}
434	view := func(f *Frame) {
435		root := f.Root()
436		for _, k := range order {
437			s := root.Scroll(Key(k))
438			s.Button(k, Key("control"))
439		}
440	}
441	r.Build(view)
442	r.route(platformInput{Kind: "motion", X: 2, Y: 2})
443	r.route(platformInput{Kind: "axis", X: 2, Y: 2, DY: 10})
444	r.route(key("Tab"))
445	r.Build(view)
446	hover, focus := r.state.hover, r.state.focus
447	scrollID := idKey(r.Target(0))
448	if r.state.scroll[scrollID].H != 10 {
449		t.Fatal("scroll state")
450	}
451	order = []string{"b", "a"}
452	r.Redraw()
453	r.Build(view)
454	if !r.state.focus.same(focus) || r.state.scroll[scrollID].H != 10 {
455		t.Fatal("keyed focus/scroll lost")
456	}
457	if r.state.hover.same(hover) || !r.state.hover.same(r.Target(0, 0)) {
458		t.Fatal("hover should track pointer over reordered layout")
459	}
460	r.route(platformInput{Kind: "motion", X: 2, Y: 32})
461	r.Build(view)
462	if !r.state.hover.same(hover) {
463		t.Fatal("hover failed to follow keyed element at new position")
464	}
465}
466
467func TestInputDuringBuildTargetsLastCommit(t *testing.T) {
468	r := newRuntime()
469	r.styles = css.Compile(css.UA(), css.Parse(`button {height:20px}`))
470	r.Build(func(f *Frame) { f.Root().Button("old", Key("old")) })
471	r.Redraw()
472	entered, release := make(chan struct{}), make(chan struct{})
473	done := make(chan bool, 1)
474	go func() {
475		done <- r.Build(func(f *Frame) { f.Root().Button("new", Key("new")); close(entered); <-release })
476	}()
477	waitEntered(t, entered)
478	r.route(platformInput{Kind: "press", Button: pointerPrimary, X: 2, Y: 2})
479	r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 2, Y: 2})
480	if len(r.pending) != 1 || !r.pending[0].Target.same(r.Target(0)) {
481		t.Fatal("in-flight input missed last committed target")
482	}
483	close(release)
484	if !<-done {
485		t.Fatal("build failed")
486	}
487	received := false
488	r.Build(func(f *Frame) { received = f.Root().Button("old", Key("old")).Activated() })
489	if !received {
490		t.Fatal("event lost during build")
491	}
492}
493
494func TestPhysicalToLogicalBoundary(t *testing.T) {
495	for _, scale := range []float64{1.25, 1.5, 2} {
496		x, y, ok := physicalToLogical(20*scale, 10*scale, scale)
497		if !ok || x != 20 || y != 10 {
498			t.Fatalf("scale %v: %v %v %v", scale, x, y, ok)
499		}
500	}
501	if _, _, ok := physicalToLogical(2, 2, 0); ok {
502		t.Fatal("zero scale")
503	}
504}
505
506func TestPointerLeaveClearsHoverKeepsCapture(t *testing.T) {
507	r := newRuntime()
508	r.styles = css.Compile(css.UA(), css.Parse(`button {height:20px;background:blue} button:hover {background:red}`))
509	activations := 0
510	view := func(f *Frame) {
511		if f.Root().Button("go", Key("go")).Activated() {
512			activations++
513		}
514	}
515	r.Build(view)
516	r.route(platformInput{Kind: "motion", X: 2, Y: 2})
517	r.Build(view)
518	if !r.state.inside || !r.state.hover.same(r.Target(0)) || r.committed.children[0].computed.Style.BackgroundColor.R != 1 {
519		t.Fatal("hover not applied")
520	}
521	r.route(platformInput{Kind: "leave"})
522	r.Build(view)
523	if r.state.inside || r.state.hover != nil || r.committed.children[0].computed.Style.BackgroundColor.B != 1 {
524		t.Fatal("leave did not clear hover style")
525	}
526	r.route(platformInput{Kind: "leave"})
527	if r.Build(view) {
528		t.Fatal("duplicate leave invalidated")
529	}
530	r.route(platformInput{Kind: "press", Button: pointerPrimary, X: 2, Y: 2})
531	r.Build(view)
532	if !r.state.down || !r.state.capture.same(r.Target(0)) {
533		t.Fatal("no capture")
534	}
535	r.route(platformInput{Kind: "leave"})
536	r.Build(view)
537	if r.state.inside || r.state.hover != nil || !r.state.down || !r.state.capture.same(r.Target(0)) {
538		t.Fatal("leave cancelled capture or kept hover")
539	}
540	r.route(platformInput{Kind: "release", Button: pointerPrimary, X: 2, Y: 2})
541	r.Build(view)
542	if activations != 0 || r.state.down || r.state.capture != nil || r.state.inside || r.state.hover != nil {
543		t.Fatal("outside release activated or kept capture/hover")
544	}
545	if r.Build(view) {
546		t.Fatal("idle redraw after leave/release")
547	}
548}

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 12

↑↓ moveenter openesc close