v0.2.0 6e0cddc

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

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