v0.1.0 47aaa0f

controls_snapshot_test.go RawHistoryFolder

1package ui
2
3import (
4	"bytes"
5	"encoding/json"
6	"fmt"
7	"image"
8	"os"
9	"path/filepath"
10	"testing"
11
12	"github.com/bnema/nefergui/internal/css"
13	"github.com/bnema/nefergui/internal/edit"
14	"github.com/bnema/nefergui/internal/text"
15)
16
17// The golden is the serialized display list, not a rendered image; each case
18// still checks the intended pseudo-state/event before comparing exact JSON.
19func TestControlDisplaySnapshots(t *testing.T) {
20	catalog, err := text.Load(text.DirectorySource("../../testdata/fonts"))
21	if err != nil {
22		t.Fatal(err)
23	}
24	controls := []string{"text", "heading", "image", "icon", "separator", "spacer", "button", "checkbox", "radio", "slider", "input", "textarea"}
25	states := []string{"normal", "hover", "focus-visible", "disabled", "checked"}
26	for _, kind := range controls {
27		for _, state := range states {
28			if (state == "checked" && kind != "checkbox" && kind != "radio") || ((state == "focus-visible" || state == "disabled") && kind != "button" && kind != "checkbox" && kind != "radio" && kind != "slider" && kind != "input" && kind != "textarea") {
29				continue
30			}
31			t.Run(kind+"/"+state, func(t *testing.T) {
32				r := newRuntime()
33				r.setTextEngine(text.NewEngine(catalog))
34				r.styles = css.Compile(css.UA(), css.Parse(`text,heading,image,icon,separator,spacer,button,checkbox,radio,slider,input,textarea {width:120px;height:28px;font-size:16px;background:#ddeeff} button:hover,checkbox:hover,radio:hover,slider:hover,input:hover,textarea:hover {background:#aaccee} button:focus-visible,checkbox:focus-visible,radio:focus-visible,slider:focus-visible,input:focus-visible,textarea:focus-visible {outline:2px solid red} button:disabled,checkbox:disabled,radio:disabled,slider:disabled,input:disabled,textarea:disabled {opacity:0.5}`))
35				checked := state == "checked"
36				choice := ""
37				if checked {
38					choice = "yes"
39				}
40				v := 3.0
41				value := "abc"
42				disabled := state == "disabled"
43				view := func(f *Frame) {
44					root := f.Root()
45					switch kind {
46					case "text":
47						root.Text("abc")
48					case "heading":
49						root.Heading("abc")
50					case "image":
51						root.Image(image.NewRGBA(image.Rect(0, 0, 12, 12)))
52					case "icon":
53						root.Icon("star")
54					case "separator":
55						root.Separator()
56					case "spacer":
57						root.Spacer()
58					case "button":
59						root.Button("abc", Disabled(disabled))
60					case "checkbox":
61						root.Checkbox("abc", &checked, Disabled(disabled))
62					case "radio":
63						root.Radio("abc", "yes", &choice, Disabled(disabled))
64					case "slider":
65						root.Slider("abc", &v, 0, 10, 1, Disabled(disabled))
66					case "input":
67						root.Input("abc", &value, Disabled(disabled))
68					case "textarea":
69						root.Textarea("abc", &value, Disabled(disabled))
70					}
71				}
72				r.Build(view)
73				switch state {
74				case "hover":
75					r.route(platformInput{Kind: "motion", X: 2, Y: 2})
76					r.Build(view)
77					if r.state.hover == nil {
78						t.Fatal("no hover")
79					}
80				case "focus-visible":
81					r.route(key("Tab"))
82					r.Build(view)
83					if !r.state.visible {
84						t.Fatal("no keyboard focus")
85					}
86				}
87				data, err := json.MarshalIndent(r.output.Display, "", "  ")
88				if err != nil {
89					t.Fatal(err)
90				}
91				assertSnapshot(t, kind+"_"+state, data)
92			})
93		}
94	}
95	for _, name := range []string{"selection-ltr", "selection-arabic", "preedit"} {
96		t.Run("input/"+name, func(t *testing.T) {
97			r := newRuntime()
98			r.setTextEngine(text.NewEngine(catalog))
99			r.styles = css.Compile(css.UA(), css.Parse(`input {width:120px;height:28px;font-size:16px;background:#ddeeff}`))
100			value := "abc"
101			if name == "selection-arabic" {
102				value = "مرحبا"
103			}
104			view := func(f *Frame) { f.Root().Input("", &value) }
105			r.Build(view)
106			r.route(key("Tab"))
107			r.Build(view)
108			s := r.edits[idKey(r.Target(0))]
109			if name == "preedit" {
110				r.route(platformInput{Kind: "ime-done", IME: edit.IMEBatch{Preedit: "é", Begin: len("é")}})
111				r.Build(view)
112			} else {
113				s.Select(0, len(value))
114				r.Redraw()
115				r.Build(view)
116			}
117			data, err := json.MarshalIndent(r.output.Display, "", "  ")
118			if err != nil {
119				t.Fatal(err)
120			}
121			assertSnapshot(t, "input_"+name, data)
122		})
123	}
124}
125func assertSnapshot(t *testing.T, name string, data []byte) {
126	t.Helper()
127	path := filepath.Join("testdata", "control-display", name+".json")
128	if os.Getenv("NEFERGUI_WRITE_SNAPSHOTS") == "1" {
129		if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
130			t.Fatal(err)
131		}
132		if err := os.WriteFile(path, append(data, '\n'), 0644); err != nil {
133			t.Fatal(err)
134		}
135	}
136	want, err := os.ReadFile(path)
137	if err != nil {
138		t.Fatalf("missing snapshot %s: %v", name, err)
139	}
140	if !bytes.Equal(bytes.TrimSpace(want), bytes.TrimSpace(data)) {
141		t.Fatalf("snapshot %s changed (got %d bytes, want %d): %s", name, len(data), len(want), fmt.Sprintf("first 120: %s", data[:min(len(data), 120)]))
142	}
143}

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