v0.5.1 657f658

indicator_paint_test.go RawHistoryFolder

1package ui
2
3import (
4	"math"
5	"testing"
6
7	"github.com/bnema/nefergui/internal/css"
8	"github.com/bnema/nefergui/internal/layout"
9)
10
11func ownCommands(r *runtime, id string) []layout.Command {
12	var out []layout.Command
13	for _, cmd := range r.output.Display {
14		if cmd.ID == id {
15			out = append(out, cmd)
16		}
17	}
18	return out
19}
20
21func accentRect(cmds []layout.Command, accent css.Color) (layout.Rect, bool) {
22	for _, cmd := range cmds {
23		if cmd.Op == "rect" && cmd.Color == accent {
24			return cmd.Rect, true
25		}
26	}
27	return layout.Rect{}, false
28}
29
30// Checkbox and radio paint an outlined indicator when unchecked and an
31// accent-filled one when checked, inside the reserved left padding; author
32// accent-color and :checked drive them.
33func TestCheckIndicatorsFollowStateAndCSS(t *testing.T) {
34	r := newRuntime()
35	r.styles = css.Compile(css.UA(), css.Parse(`checkbox, radio {width:200px;height:24px;font-size:16px} checkbox:checked {accent-color:#00ff00} radio {accent-color:#ff0000}`))
36	checked, choice := false, ""
37	view := func(f *Frame) {
38		root := f.Root()
39		root.Checkbox("Box", &checked, Key("box"))
40		root.Radio("A", "a", &choice, Key("a"))
41	}
42	green, red := css.Color{G: 1, A: 1}, css.Color{R: 1, A: 1}
43	for _, on := range []bool{false, true} {
44		checked = on
45		choice = map[bool]string{true: "a"}[on]
46		r.Redraw() // the model changed outside NeferGUI's events
47		r.Build(view)
48		for id, accent := range map[string]css.Color{"root/k3:box": green, "root/k1:a": red} {
49			cmds := ownCommands(r, id)
50			n := resultByID(r.output.Tree, id)
51			rect, filled := accentRect(cmds, accent)
52			if filled != on {
53				t.Fatalf("%s checked=%v: accent fill %v in %+v", id, on, filled, cmds)
54			}
55			if !on {
56				found := false
57				for _, cmd := range cmds {
58					if cmd.Op == "border" && cmd.Rect.W == 16 {
59						found, rect = true, cmd.Rect
60					}
61				}
62				if !found {
63					t.Fatalf("%s unchecked: no outlined indicator in %+v", id, cmds)
64				}
65			}
66			if rect.W != 16 || rect.H != 16 || rect.X+rect.W > n.Content.X || rect.X < n.Border.X {
67				t.Fatalf("%s indicator %+v outside the reserved padding (content %+v)", id, rect, n.Content)
68			}
69		}
70	}
71}
72
73// The slider paints a rail across the pointer track, then fill and thumb at
74// the value fraction; the thumb follows the value after a key change.
75func TestSliderTrackFollowsValue(t *testing.T) {
76	r := newRuntime()
77	r.styles = css.Compile(css.UA(), css.Parse(`slider {width:200px;font-size:16px;accent-color:#0000ff}`))
78	v := 2.5
79	view := func(f *Frame) { f.Root().Slider("Volume", &v, 0, 10, 0.5, Key("s")) }
80	r.Build(view)
81	id := "root/k1:s"
82	n := resultByID(r.output.Tree, id)
83	blue := css.Color{B: 1, A: 1}
84	thumbX := func() float64 {
85		t.Helper()
86		var rects []layout.Rect
87		for _, cmd := range ownCommands(r, id) {
88			if cmd.Op == "rect" && cmd.Color == blue {
89				rects = append(rects, cmd.Rect)
90			}
91		}
92		if len(rects) != 2 {
93			t.Fatalf("want fill and thumb, got %+v", rects)
94		}
95		thumb := rects[1]
96		if thumb.Y < n.Content.Y+n.Content.H || thumb.Y+thumb.H > n.Border.Y+n.Border.H {
97			t.Fatalf("thumb %+v outside the reserved bottom band of %+v", thumb, n.Border)
98		}
99		return thumb.X + thumb.W/2
100	}
101	if got, want := thumbX(), n.Content.X+0.25*n.Content.W; got != want {
102		t.Fatalf("thumb center %v, want %v", got, want)
103	}
104	r.route(platformInput{Kind: "press", X: n.Content.X + 0.75*n.Content.W, Y: n.Content.Y + 1, Button: pointerPrimary})
105	r.Build(view)
106	r.Build(view)
107	if v != 7.5 {
108		t.Fatalf("pointer value %v", v)
109	}
110	if got, want := thumbX(), n.Content.X+0.75*n.Content.W; got != want {
111		t.Fatalf("thumb center after press %v, want %v", got, want)
112	}
113}
114
115// Edge cases: a clipping control keeps its padding paint outside the content
116// clip, a slider without bottom padding paints no track, the unchecked
117// outline carries its muted alpha once, disabled checkboxes are dimmed, and
118// extreme finite ranges still give a finite thumb position.
119func TestIndicatorEdgeCases(t *testing.T) {
120	r := newRuntime()
121	r.styles = css.Compile(css.UA(), css.Parse(`checkbox {width:200px;height:24px;font-size:16px;overflow:hidden} slider {width:200px;padding-bottom:0}`))
122	checked, v, off := false, 1.0, false
123	r.Build(func(f *Frame) {
124		root := f.Root()
125		root.Checkbox("Box", &checked, Key("box"))
126		root.Checkbox("Off", &off, Key("off"), Disabled(true))
127		root.Slider("S", &v, 0, 10, 1, Key("s"))
128	})
129	clipped, outlined := false, false
130	for _, cmd := range r.output.Display {
131		if cmd.ID != "root/k3:box" {
132			continue
133		}
134		if cmd.Op == "clip-push" {
135			clipped = true
136		}
137		if cmd.Op == "border" && cmd.Rect.W == 16 {
138			outlined = true
139			if clipped {
140				t.Error("indicator painted inside the content clip")
141			}
142			if cmd.Color.A != 1 || cmd.Colors.Top.A != 0.45 {
143				t.Errorf("outline alpha color=%v side=%v, want opaque base and 0.45 sides", cmd.Color.A, cmd.Colors.Top.A)
144			}
145		}
146	}
147	if !clipped || !outlined {
148		t.Fatalf("clipped checkbox: clip=%v indicator=%v", clipped, outlined)
149	}
150	dimmed := false
151	for _, cmd := range ownCommands(r, "root/k3:off") {
152		if cmd.Op == "border" && cmd.Rect.W == 16 {
153			dimmed = true
154			if cmd.Opacity != 0.5 {
155				t.Errorf("disabled checkbox indicator opacity %v", cmd.Opacity)
156			}
157		}
158	}
159	if !dimmed {
160		t.Error("disabled checkbox indicator missing")
161	}
162	for _, cmd := range ownCommands(r, "root/k1:s") {
163		if cmd.Op == "rect" {
164			t.Errorf("slider without bottom padding painted %+v", cmd)
165			break
166		}
167	}
168	for _, c := range []struct{ v, min, max, want float64 }{
169		{0, -math.MaxFloat64, math.MaxFloat64, 0.5},
170		{math.MaxFloat64, -math.MaxFloat64, math.MaxFloat64, 1},
171		{5, 5, 5, 0},
172		{2.5, 0, 10, 0.25},
173		{math.SmallestNonzeroFloat64, 0, 2 * math.SmallestNonzeroFloat64, 0.5},
174		{math.NaN(), 0, 1, 0},
175	} {
176		if got := sliderFraction(c.v, c.min, c.max); got != c.want {
177			t.Errorf("sliderFraction(%v,%v,%v)=%v want %v", c.v, c.min, c.max, got, c.want)
178		}
179	}
180}

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